diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index 73eb9da526..d79b0ff902 100644 --- a/src/components/Menu/index.tsx +++ b/src/components/Menu/index.tsx @@ -190,6 +190,55 @@ export function ItemIcon({icon: Comp}: ItemIconProps) { ) } +export function ItemRadio({selected}: {selected: boolean}) { + const t = useTheme() + return ( + + {selected ? ( + + ) : null} + + ) +} + +export function LabelText({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + + {children} + + ) +} + export function Group({children, style}: GroupProps) { const t = useTheme() return ( diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx index ab0c9d20a1..bc8596218c 100644 --- a/src/components/Menu/index.web.tsx +++ b/src/components/Menu/index.web.tsx @@ -304,6 +304,57 @@ export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) { ) } +export function ItemRadio({selected}: {selected: boolean}) { + const t = useTheme() + return ( + + {selected ? ( + + ) : null} + + ) +} + +export function LabelText({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + + {children} + + ) +} + export function Group({children}: GroupProps) { return children } diff --git a/src/screens/Settings/ThreadPreferences.tsx b/src/screens/Settings/ThreadPreferences.tsx index b1547e495e..701d3d9e56 100644 --- a/src/screens/Settings/ThreadPreferences.tsx +++ b/src/screens/Settings/ThreadPreferences.tsx @@ -148,7 +148,7 @@ export function ThreadPreferencesScreen({}: Props) { } style={[a.w_full, a.gap_md]}> - Show replies in a threaded view + Show replies as threaded diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index af58edcbfe..a0073b02f9 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -1,4 +1,4 @@ -import React, {useRef} from 'react' +import React, {memo, useRef, useState} from 'react' import {StyleSheet, useWindowDimensions, View} from 'react-native' import {runOnJS} from 'react-native-reanimated' import Animated from 'react-native-reanimated' @@ -7,6 +7,7 @@ import {AppBskyFeedDefs, AppBskyFeedThreadgate} 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 {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform' import {useSetTitle} from '#/lib/hooks/useSetTitle' @@ -28,14 +29,18 @@ import { 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 {useComposerControls} from '#/state/shell' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {List, 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' @@ -107,12 +112,47 @@ export function PostThread({uri}: {uri: string | undefined}) { 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 persistance). + // 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( - () => - !!preferences?.threadViewPrefs?.lab_treeViewEnabled && - hasBranchingReplies(thread), - [preferences?.threadViewPrefs, thread], + () => 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 @@ -175,13 +215,16 @@ export function PostThread({uri}: {uri: string | undefined}) { const [fetchedAtCache] = React.useState(() => new Map()) const [randomCache] = React.useState(() => new Map()) const skeleton = React.useMemo(() => { - const threadViewPrefs = preferences?.threadViewPrefs - if (!threadViewPrefs || !thread) return null - + if (!thread) return null return createThreadSkeleton( sortThread( thread, - threadViewPrefs, + { + // Prefer local state as the source of truth. + sort: sortReplies, + lab_treeViewEnabled: treeViewEnabled, + prioritizeFollowedUsers, + }, threadModerationCache, currentDid, justPostedUris, @@ -198,7 +241,9 @@ export function PostThread({uri}: {uri: string | undefined}) { ) }, [ thread, - preferences?.threadViewPrefs, + prioritizeFollowedUsers, + sortReplies, + treeViewEnabled, currentDid, treeView, threadModerationCache, @@ -484,14 +529,21 @@ export function PostThread({uri}: {uri: string | undefined}) { return ( <> - + Post - + + + @@ -537,6 +589,122 @@ export function PostThread({uri}: {uri: string | undefined}) { ) } +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 safeAreaInsets = useSafeAreaInsets() const fabMinimalShellTransform = useMinimalShellFabTransform()