diff --git a/src/screens/PostThread/components/HeaderDropdown.tsx b/src/screens/PostThread/components/HeaderDropdown.tsx
new file mode 100644
index 0000000000..a53f5dac75
--- /dev/null
+++ b/src/screens/PostThread/components/HeaderDropdown.tsx
@@ -0,0 +1,102 @@
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {HITSLOP_10} from '#/lib/constants'
+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 {
+ const {_} = useLingui()
+ return (
+
+
+ {({props}) => (
+
+ )}
+
+
+
+ Show replies as
+
+
+ {
+ setTreeViewEnabled(false)
+ }}>
+
+ Linear
+
+
+
+ {
+ setTreeViewEnabled(true)
+ }}>
+
+ Threaded
+
+
+
+
+
+
+ Reply sorting
+
+
+ {
+ setSortReplies('top')
+ }}>
+
+ Top replies first
+
+
+
+ {
+ setSortReplies('oldest')
+ }}>
+
+ Oldest replies first
+
+
+
+ {
+ setSortReplies('newest')
+ }}>
+
+ Newest replies first
+
+
+
+
+
+
+ )
+}
diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx
new file mode 100644
index 0000000000..b58254ba21
--- /dev/null
+++ b/src/screens/PostThread/index.tsx
@@ -0,0 +1,313 @@
+import {useMemo, useRef, useState} from 'react'
+import {useWindowDimensions, View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
+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 {
+ HiddenReplyKind,
+ type Slice,
+ usePostThread,
+} from '#/state/queries/usePostThread'
+import {type OnPostSuccessData} from '#/state/shell/composer'
+import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
+import {PostThreadItem} from '#/view/com/post-thread/PostThreadItem'
+import {PostThreadShowHiddenReplies} from '#/view/com/post-thread/PostThreadShowHiddenReplies'
+import {List, type ListMethods} from '#/view/com/util/List'
+import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown'
+import {ReadMore} from '#/screens/PostThread/components/ReadMore'
+import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import * as Layout from '#/components/Layout'
+import {ListFooter} from '#/components/Lists'
+import {Text} from '#/components/Typography'
+
+export function Inner({uri}: {uri: string | undefined}) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {gtPhone} = useBreakpoints()
+ // const {hasSession, currentAccount} = useSession()
+ const initialNumToRender = useInitialNumToRender()
+ const {height: windowHeight} = useWindowDimensions()
+
+ const {
+ isLoaded: isThreadPreferencesLoaded,
+ sortReplies,
+ setSortReplies,
+ prioritizeFollowedUsers,
+ treeViewEnabled,
+ setTreeViewEnabled,
+ } = useThreadPreferences()
+
+ const [shownHiddenReplyKinds, setShownHiddenReplyKinds] = useState<
+ Set
+ >(new Set())
+
+ const {isFetching, error, data, refetch, insertReplies} = usePostThread({
+ enabled: isThreadPreferencesLoaded,
+ params: {
+ anchor: uri,
+ sort: sortReplies,
+ view: treeViewEnabled ? 'tree' : 'linear',
+ prioritizeFollowedUsers,
+ },
+ state: {
+ shownHiddenReplyKinds,
+ },
+ })
+
+ const optimisticOnPostReply = (data: OnPostSuccessData) => {
+ if (data) {
+ const {replyToUri, posts} = data
+ if (replyToUri && posts.length) {
+ insertReplies(replyToUri, posts)
+ }
+ }
+ }
+
+ const {openComposer} = useOpenComposer()
+ const onReplyToAnchor = () => {
+ const anchorPost = data?.items.find(
+ slice => slice.type === 'threadPost' && slice.ui.isAnchor,
+ )
+ if (anchorPost?.type !== 'threadPost') {
+ return
+ }
+ const post = anchorPost.value.post
+ openComposer({
+ replyTo: {
+ uri: anchorPost.uri,
+ cid: post.cid,
+ text: post.record.text,
+ author: post.author,
+ embed: post.embed,
+ moderation: anchorPost.moderation,
+ },
+ onPostSuccess: optimisticOnPostReply,
+ })
+ }
+
+ const listRef = useRef(null)
+ const headerRef = useRef(null)
+ const anchorRef = useRef(null)
+ /**
+ * WEB ONLY
+ *
+ * Fires any time the content of the list changes. If user switches back to a
+ * sort that was rendered previously, this does NOT fire. Therefore, scroll
+ * is only reset to the anchor on initial render, or fresh data.
+ *
+ * When this fires, the `List` is scrolled all the way to the top, so
+ * measurements taken from `top` correspond to the top of the screen. This
+ * handler scrolls the `List` to the top of the highlighted post, minus any
+ * fixed elements.
+ */
+ const onContentSizeChangeWebOnly = web(() => {
+ const anchorElement = anchorRef.current as any as Element
+ const headerElement = headerRef.current as any as Element
+ if (anchorElement && headerElement) {
+ // distance from top of the list (screen)
+ const anchorOffsetTop = anchorElement.getBoundingClientRect().top
+ const headerHeight = headerElement.getBoundingClientRect().height
+ // don't scroll past 0
+ const scrollPosition = Math.max(0, anchorOffsetTop - headerHeight)
+ listRef.current?.scrollToOffset({
+ animated: false,
+ offset: scrollPosition,
+ })
+ }
+ })
+
+ /*
+ * On native, any time we navigate to a new post/reply (even if the data is
+ * cached), we skip rendering parents so that the anchor post is the first
+ * item in the list. That way,
+ * `maintainVisibleContentPosition={{minIndexForVisible: 0}}` will pin the
+ * anchor post to the top of the screen, and on the next render, we'll
+ * include parents.
+ *
+ * On the web this is not necessary because we can synchronously adjust the
+ * scroll in onContentSizeChange instead.
+ */
+ const [deferParents, setDeferParents] = useState(isNative)
+ const items = useMemo(() => {
+ return (data?.items ?? []).filter(item => {
+ return !('depth' in item) || item.depth >= 0 || !deferParents
+ })
+ }, [data, deferParents])
+
+ const renderItem = ({item, index}: {item: Slice; index: number}) => {
+ if (item.type === 'threadPost') {
+ return (
+ setDeferParents(false) : undefined}>
+ 0
+ }
+ onPostSuccess={optimisticOnPostReply}
+ hideTopBorder={index === 0} // && !item.isParentLoading} // TODO
+ />
+
+ )
+ } else if (item.type === 'readMore') {
+ return
+ } else if (item.type === 'threadPostBlocked') {
+ return (
+
+
+ Blocked post.
+
+
+ )
+ } else if (item.type === 'threadPostNotFound') {
+ return (
+
+
+ Deleted post.
+
+
+ )
+ } else if (item.type === 'replyComposer') {
+ return (
+
+ {gtPhone && (
+
+ )}
+
+ )
+ } else if (item.type === 'showHiddenReplies') {
+ return (
+
+ setShownHiddenReplyKinds(kinds => new Set([...kinds, item.kind]))
+ }
+ />
+ )
+ }
+ return null
+ }
+
+ return (
+ <>
+
+
+
+
+ Post
+
+
+
+
+
+
+
+ {error ? (
+
+ ) : (
+
+
+ }
+ initialNumToRender={initialNumToRender}
+ windowSize={11}
+ sideBorders={false}
+ />
+
+ )}
+ >
+ )
+}
+
+function PostThreadError({error}: {error: Error}) {
+ const {_} = useLingui()
+
+ // TODO use new cleanError hook
+ const {title: _title, message: _message} = useMemo(() => {
+ let title = _(msg`An error occurred`)
+ let message = cleanError(error)
+
+ if (error.message.startsWith('Post not found')) {
+ title = _(msg`Post not found`)
+ message = _(msg`The post may have been deleted.`)
+ }
+ return {title, message}
+ }, [_, error])
+
+ return
+}
+
+const keyExtractor = (item: Slice) => {
+ return item.key
+}
diff --git a/src/state/queries/preferences/useThreadPreferences.ts b/src/state/queries/preferences/useThreadPreferences.ts
new file mode 100644
index 0000000000..b4a00a8f0b
--- /dev/null
+++ b/src/state/queries/preferences/useThreadPreferences.ts
@@ -0,0 +1,59 @@
+import {useMemo, useState} from 'react'
+
+import {usePreferencesQuery} from '#/state/queries/preferences'
+
+export function useThreadPreferences() {
+ const {data: preferences} = usePreferencesQuery()
+ const nextThreadPreferences = preferences?.threadViewPrefs
+
+ /*
+ * Create local state representations of server state
+ */
+ const [sortReplies, setSortReplies] = useState(
+ nextThreadPreferences?.sort ?? 'hotness',
+ )
+ 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
+ */
+ const [prevServerPrefs, setPrevServerPrefs] = useState(nextThreadPreferences)
+ if (nextThreadPreferences && prevServerPrefs !== nextThreadPreferences) {
+ setPrevServerPrefs(nextThreadPreferences)
+
+ /*
+ * Reset
+ */
+ setSortReplies(nextThreadPreferences.sort)
+ setPrioritizeFollowedUsers(nextThreadPreferences.prioritizeFollowedUsers)
+ setTreeViewEnabled(!!nextThreadPreferences.lab_treeViewEnabled)
+ }
+
+ const isLoaded = !!prevServerPrefs
+
+ return useMemo(
+ () => ({
+ isLoaded,
+ sortReplies,
+ setSortReplies,
+ prioritizeFollowedUsers,
+ setPrioritizeFollowedUsers,
+ treeViewEnabled,
+ setTreeViewEnabled,
+ }),
+ [
+ isLoaded,
+ sortReplies,
+ setSortReplies,
+ prioritizeFollowedUsers,
+ setPrioritizeFollowedUsers,
+ treeViewEnabled,
+ setTreeViewEnabled,
+ ],
+ )
+}
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index 6a5f2a0c76..b88cf2cb4c 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -1,45 +1,15 @@
-import {useCallback, useMemo, useRef, useState} from 'react'
-import {useWindowDimensions, View} from 'react-native'
-import {msg, Trans, Plural} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
+import {useCallback} from 'react'
import {useFocusEffect} from '@react-navigation/native'
-import {HITSLOP_10} from '#/lib/constants'
// import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread'
-import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
-import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
-import {makeProfileLink} from '#/lib/routes/links'
import {
type CommonNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
-import {ScrollProvider} from '#/lib/ScrollContext'
-import {cleanError} from '#/lib/strings/errors'
import {makeRecordUri} from '#/lib/strings/url-helpers'
-import {isNative} from '#/platform/detection'
-import {usePreferencesQuery} from '#/state/queries/preferences'
-import {
- HiddenReplyKind,
- type Slice,
- usePostThread,
- PostThreadParams,
-} from '#/state/queries/usePostThread'
import {useSetMinimalShellMode} from '#/state/shell'
-import {type OnPostSuccessData} from '#/state/shell/composer'
-import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
-import {PostThreadItem} from '#/view/com/post-thread/PostThreadItem'
-import {PostThreadShowHiddenReplies} from '#/view/com/post-thread/PostThreadShowHiddenReplies'
-import {List, type ListMethods} from '#/view/com/util/List'
-import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
-import {Button, ButtonIcon} from '#/components/Button'
-import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlus} from '#/components/icons/CirclePlus'
-import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
+import {Inner} from '#/screens/PostThread'
import * as Layout from '#/components/Layout'
-import {Link} from '#/components/Link'
-import {ListFooter} from '#/components/Lists'
-import * as Menu from '#/components/Menu'
-import {Text} from '#/components/Typography'
-import {ReadMore} from '#/screens/PostThread/components/ReadMore'
type Props = NativeStackScreenProps
export function PostThreadScreen({route}: Props) {
@@ -61,442 +31,3 @@ export function PostThreadScreen({route}: Props) {
)
}
-
-function useThreadPreferences() {
- const {data: preferences} = usePreferencesQuery()
- const nextThreadPreferences = preferences?.threadViewPrefs
-
- /*
- * Create local state representations of server state
- */
- const [sortReplies, setSortReplies] = useState(
- nextThreadPreferences?.sort ?? 'hotness',
- )
- 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
- */
- const [prevServerPrefs, setPrevServerPrefs] = useState(nextThreadPreferences)
- if (nextThreadPreferences && prevServerPrefs !== nextThreadPreferences) {
- setPrevServerPrefs(nextThreadPreferences)
-
- /*
- * Reset
- */
- setSortReplies(nextThreadPreferences.sort)
- setPrioritizeFollowedUsers(nextThreadPreferences.prioritizeFollowedUsers)
- setTreeViewEnabled(!!nextThreadPreferences.lab_treeViewEnabled)
- }
-
- const isLoaded = !!prevServerPrefs
-
- return useMemo(
- () => ({
- isLoaded,
- sortReplies,
- setSortReplies,
- prioritizeFollowedUsers,
- setPrioritizeFollowedUsers,
- treeViewEnabled,
- setTreeViewEnabled,
- }),
- [
- isLoaded,
- sortReplies,
- setSortReplies,
- prioritizeFollowedUsers,
- setPrioritizeFollowedUsers,
- treeViewEnabled,
- setTreeViewEnabled,
- ],
- )
-}
-
-export function Inner({uri}: {uri: string | undefined}) {
- const t = useTheme()
- const {_} = useLingui()
- const {gtPhone} = useBreakpoints()
- // const {hasSession, currentAccount} = useSession()
- const initialNumToRender = useInitialNumToRender()
- const {height: windowHeight} = useWindowDimensions()
-
- const {
- isLoaded: isThreadPreferencesLoaded,
- sortReplies,
- setSortReplies,
- prioritizeFollowedUsers,
- treeViewEnabled,
- setTreeViewEnabled,
- } = useThreadPreferences()
-
- const [shownHiddenReplyKinds, setShownHiddenReplyKinds] = useState<
- Set
- >(new Set())
-
- const {isFetching, error, data, refetch, insertReplies} = usePostThread({
- enabled: isThreadPreferencesLoaded,
- params: {
- anchor: uri,
- sort: sortReplies,
- view: treeViewEnabled ? 'tree' : 'linear',
- prioritizeFollowedUsers,
- },
- state: {
- shownHiddenReplyKinds,
- },
- })
-
- const optimisticOnPostReply = (data: OnPostSuccessData) => {
- if (data) {
- const {replyToUri, posts} = data
- if (replyToUri && posts.length) {
- insertReplies(replyToUri, posts)
- }
- }
- }
-
- const {openComposer} = useOpenComposer()
- const onReplyToAnchor = () => {
- const anchorPost = data?.items.find(
- slice => slice.type === 'threadPost' && slice.ui.isAnchor,
- )
- if (anchorPost?.type !== 'threadPost') {
- return
- }
- const post = anchorPost.value.post
- openComposer({
- replyTo: {
- uri: anchorPost.uri,
- cid: post.cid,
- text: post.record.text,
- author: post.author,
- embed: post.embed,
- moderation: anchorPost.moderation,
- },
- onPostSuccess: optimisticOnPostReply,
- })
- }
-
- const listRef = useRef(null)
- const headerRef = useRef(null)
- const anchorRef = useRef(null)
- /**
- * WEB ONLY
- *
- * Fires any time the content of the list changes. If user switches back to a
- * sort that was rendered previously, this does NOT fire. Therefore, scroll
- * is only reset to the anchor on initial render, or fresh data.
- *
- * When this fires, the `List` is scrolled all the way to the top, so
- * measurements taken from `top` correspond to the top of the screen. This
- * handler scrolls the `List` to the top of the highlighted post, minus any
- * fixed elements.
- */
- const onContentSizeChangeWebOnly = web(() => {
- const anchorElement = anchorRef.current as any as Element
- const headerElement = headerRef.current as any as Element
- if (anchorElement && headerElement) {
- // distance from top of the list (screen)
- const anchorOffsetTop = anchorElement.getBoundingClientRect().top
- const headerHeight = headerElement.getBoundingClientRect().height
- // don't scroll past 0
- const scrollPosition = Math.max(0, anchorOffsetTop - headerHeight)
- listRef.current?.scrollToOffset({
- animated: false,
- offset: scrollPosition,
- })
- }
- })
-
- /*
- * On native, any time we navigate to a new post/reply (even if the data is
- * cached), we skip rendering parents so that the anchor post is the first
- * item in the list. That way,
- * `maintainVisibleContentPosition={{minIndexForVisible: 0}}` will pin the
- * anchor post to the top of the screen, and on the next render, we'll
- * include parents.
- *
- * On the web this is not necessary because we can synchronously adjust the
- * scroll in onContentSizeChange instead.
- */
- const [deferParents, setDeferParents] = useState(isNative)
- const items = useMemo(() => {
- return (data?.items ?? []).filter(item => {
- return !('depth' in item) || item.depth >= 0 || !deferParents
- })
- }, [data, deferParents])
-
- const renderItem = ({item, index}: {item: Slice; index: number}) => {
- if (item.type === 'threadPost') {
- return (
- setDeferParents(false) : undefined}>
- 0
- }
- onPostSuccess={optimisticOnPostReply}
- hideTopBorder={index === 0} // && !item.isParentLoading} // TODO
- />
-
- )
- } else if (item.type === 'readMore') {
- return (
-
- )
- } else if (item.type === 'threadPostBlocked') {
- return (
-
-
- Blocked post.
-
-
- )
- } else if (item.type === 'threadPostNotFound') {
- return (
-
-
- Deleted post.
-
-
- )
- } else if (item.type === 'replyComposer') {
- return (
-
- {gtPhone && (
-
- )}
-
- )
- } else if (item.type === 'showHiddenReplies') {
- return (
-
- setShownHiddenReplyKinds(kinds => new Set([...kinds, item.kind]))
- }
- />
- )
- }
- return null
- }
-
- return (
- <>
-
-
-
-
- Post
-
-
-
-
-
-
-
- {error ? (
-
- ) : (
-
-
- }
- initialNumToRender={initialNumToRender}
- windowSize={11}
- sideBorders={false}
- />
-
- )}
- >
- )
-}
-
-function PostThreadError({error}: {error: Error}) {
- const {_} = useLingui()
-
- // TODO use new cleanError hook
- const {title: _title, message: _message} = useMemo(() => {
- let title = _(msg`An error occurred`)
- let message = cleanError(error)
-
- if (error.message.startsWith('Post not found')) {
- title = _(msg`Post not found`)
- message = _(msg`The post may have been deleted.`)
- }
- return {title, message}
- }, [_, error])
-
- return
-}
-
-function 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('top')
- }}>
-
- Top replies first
-
-
-
- {
- setSortReplies('oldest')
- }}>
-
- Oldest replies first
-
-
-
- {
- setSortReplies('newest')
- }}>
-
- Newest replies first
-
-
-
-
-
-
- )
-}
-
-const keyExtractor = (item: Slice) => {
- return item.key
-}