From e1587661f4631a064d52a65e322e4227988496ec Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 27 May 2025 13:41:19 -0500 Subject: [PATCH] More reorg --- .../PostThread/components/HeaderDropdown.tsx | 102 ++++ src/screens/PostThread/index.tsx | 313 ++++++++++++ .../preferences/useThreadPreferences.ts | 59 +++ src/view/screens/PostThread.tsx | 473 +----------------- 4 files changed, 476 insertions(+), 471 deletions(-) create mode 100644 src/screens/PostThread/components/HeaderDropdown.tsx create mode 100644 src/screens/PostThread/index.tsx create mode 100644 src/state/queries/preferences/useThreadPreferences.ts 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 -}