Add reply sorting in-thread (#7156)
* Add button * Extract component * Make it work * Extract and use RadioCircle * Add tree/list setting * Prefer local state * Factor out threadViewPrefs * Fix optimistic stuff * Revert RadioButton changes * Tweak radio styles, add Menu.LabelText * Labels * Margins * Update copy --------- Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
@@ -190,6 +190,55 @@ export function ItemIcon({icon: Comp}: ItemIconProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ItemRadio({selected}: {selected: boolean}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.justify_center,
|
||||||
|
a.align_center,
|
||||||
|
a.rounded_full,
|
||||||
|
t.atoms.border_contrast_high,
|
||||||
|
{
|
||||||
|
borderWidth: 1,
|
||||||
|
height: 24,
|
||||||
|
width: 24,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{selected ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.rounded_full,
|
||||||
|
{height: 16, width: 16},
|
||||||
|
selected
|
||||||
|
? {
|
||||||
|
backgroundColor: t.palette.primary_500,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabelText({children}: {children: React.ReactNode}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
{
|
||||||
|
marginBottom: -8,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Group({children, style}: GroupProps) {
|
export function Group({children, style}: GroupProps) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -304,6 +304,57 @@ export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ItemRadio({selected}: {selected: boolean}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.justify_center,
|
||||||
|
a.align_center,
|
||||||
|
a.rounded_full,
|
||||||
|
t.atoms.border_contrast_high,
|
||||||
|
{
|
||||||
|
borderWidth: 1,
|
||||||
|
height: 24,
|
||||||
|
width: 24,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{selected ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.rounded_full,
|
||||||
|
{height: 16, width: 16},
|
||||||
|
selected
|
||||||
|
? {
|
||||||
|
backgroundColor: t.palette.primary_500,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabelText({children}: {children: React.ReactNode}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_bold,
|
||||||
|
a.pt_lg,
|
||||||
|
a.pb_sm,
|
||||||
|
t.atoms.text_contrast_low,
|
||||||
|
{
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Group({children}: GroupProps) {
|
export function Group({children}: GroupProps) {
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export function ThreadPreferencesScreen({}: Props) {
|
|||||||
}
|
}
|
||||||
style={[a.w_full, a.gap_md]}>
|
style={[a.w_full, a.gap_md]}>
|
||||||
<Toggle.LabelText style={[a.flex_1]}>
|
<Toggle.LabelText style={[a.flex_1]}>
|
||||||
<Trans>Show replies in a threaded view</Trans>
|
<Trans>Show replies as threaded</Trans>
|
||||||
</Toggle.LabelText>
|
</Toggle.LabelText>
|
||||||
<Toggle.Platform />
|
<Toggle.Platform />
|
||||||
</Toggle.Item>
|
</Toggle.Item>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, {useRef} from 'react'
|
import React, {memo, useRef, useState} from 'react'
|
||||||
import {StyleSheet, useWindowDimensions, View} from 'react-native'
|
import {StyleSheet, useWindowDimensions, View} from 'react-native'
|
||||||
import {runOnJS} from 'react-native-reanimated'
|
import {runOnJS} from 'react-native-reanimated'
|
||||||
import Animated 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 {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {HITSLOP_10} from '#/lib/constants'
|
||||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||||
import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
|
import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
|
||||||
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
||||||
@@ -28,14 +29,18 @@ import {
|
|||||||
ThreadPost,
|
ThreadPost,
|
||||||
usePostThreadQuery,
|
usePostThreadQuery,
|
||||||
} from '#/state/queries/post-thread'
|
} from '#/state/queries/post-thread'
|
||||||
|
import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences'
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useComposerControls} from '#/state/shell'
|
import {useComposerControls} from '#/state/shell'
|
||||||
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||||
import {List, ListMethods} from '#/view/com/util/List'
|
import {List, ListMethods} from '#/view/com/util/List'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
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 {Header} from '#/components/Layout'
|
||||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||||
|
import * as Menu from '#/components/Menu'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {PostThreadComposePrompt} from './PostThreadComposePrompt'
|
import {PostThreadComposePrompt} from './PostThreadComposePrompt'
|
||||||
import {PostThreadItem} from './PostThreadItem'
|
import {PostThreadItem} from './PostThreadItem'
|
||||||
@@ -107,12 +112,47 @@ export function PostThread({uri}: {uri: string | undefined}) {
|
|||||||
dataUpdatedAt: fetchedAt,
|
dataUpdatedAt: fetchedAt,
|
||||||
} = usePostThreadQuery(uri)
|
} = 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(
|
const treeView = React.useMemo(
|
||||||
() =>
|
() => treeViewEnabled && hasBranchingReplies(thread),
|
||||||
!!preferences?.threadViewPrefs?.lab_treeViewEnabled &&
|
[treeViewEnabled, thread],
|
||||||
hasBranchingReplies(thread),
|
|
||||||
[preferences?.threadViewPrefs, thread],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const rootPost = thread?.type === 'post' ? thread.post : undefined
|
const rootPost = thread?.type === 'post' ? thread.post : undefined
|
||||||
const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
|
const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
|
||||||
const threadgateRecord = threadgate?.record as
|
const threadgateRecord = threadgate?.record as
|
||||||
@@ -175,13 +215,16 @@ export function PostThread({uri}: {uri: string | undefined}) {
|
|||||||
const [fetchedAtCache] = React.useState(() => new Map<string, number>())
|
const [fetchedAtCache] = React.useState(() => new Map<string, number>())
|
||||||
const [randomCache] = React.useState(() => new Map<string, number>())
|
const [randomCache] = React.useState(() => new Map<string, number>())
|
||||||
const skeleton = React.useMemo(() => {
|
const skeleton = React.useMemo(() => {
|
||||||
const threadViewPrefs = preferences?.threadViewPrefs
|
if (!thread) return null
|
||||||
if (!threadViewPrefs || !thread) return null
|
|
||||||
|
|
||||||
return createThreadSkeleton(
|
return createThreadSkeleton(
|
||||||
sortThread(
|
sortThread(
|
||||||
thread,
|
thread,
|
||||||
threadViewPrefs,
|
{
|
||||||
|
// Prefer local state as the source of truth.
|
||||||
|
sort: sortReplies,
|
||||||
|
lab_treeViewEnabled: treeViewEnabled,
|
||||||
|
prioritizeFollowedUsers,
|
||||||
|
},
|
||||||
threadModerationCache,
|
threadModerationCache,
|
||||||
currentDid,
|
currentDid,
|
||||||
justPostedUris,
|
justPostedUris,
|
||||||
@@ -198,7 +241,9 @@ export function PostThread({uri}: {uri: string | undefined}) {
|
|||||||
)
|
)
|
||||||
}, [
|
}, [
|
||||||
thread,
|
thread,
|
||||||
preferences?.threadViewPrefs,
|
prioritizeFollowedUsers,
|
||||||
|
sortReplies,
|
||||||
|
treeViewEnabled,
|
||||||
currentDid,
|
currentDid,
|
||||||
treeView,
|
treeView,
|
||||||
threadModerationCache,
|
threadModerationCache,
|
||||||
@@ -484,14 +529,21 @@ export function PostThread({uri}: {uri: string | undefined}) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header.Outer sticky={true} headerRef={headerRef}>
|
<Header.Outer headerRef={headerRef}>
|
||||||
<Header.BackButton />
|
<Header.BackButton />
|
||||||
<Header.Content>
|
<Header.Content>
|
||||||
<Header.TitleText>
|
<Header.TitleText>
|
||||||
<Trans context="description">Post</Trans>
|
<Trans context="description">Post</Trans>
|
||||||
</Header.TitleText>
|
</Header.TitleText>
|
||||||
</Header.Content>
|
</Header.Content>
|
||||||
<Header.Slot />
|
<Header.Slot>
|
||||||
|
<ThreadMenu
|
||||||
|
sortReplies={sortReplies}
|
||||||
|
treeViewEnabled={treeViewEnabled}
|
||||||
|
setSortReplies={updateSortReplies}
|
||||||
|
setTreeViewEnabled={updateTreeViewEnabled}
|
||||||
|
/>
|
||||||
|
</Header.Slot>
|
||||||
</Header.Outer>
|
</Header.Outer>
|
||||||
|
|
||||||
<ScrollProvider onMomentumEnd={onMomentumEnd}>
|
<ScrollProvider onMomentumEnd={onMomentumEnd}>
|
||||||
@@ -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 (
|
||||||
|
<Menu.Root>
|
||||||
|
<Menu.Trigger label={_(msg`Thread options`)}>
|
||||||
|
{({props}) => (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Thread options`)}
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
shape="round"
|
||||||
|
hitSlop={HITSLOP_10}
|
||||||
|
{...props}>
|
||||||
|
<ButtonIcon icon={SettingsSlider} size="md" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Menu.Trigger>
|
||||||
|
<Menu.Outer>
|
||||||
|
<Menu.LabelText>
|
||||||
|
<Trans>Show replies as</Trans>
|
||||||
|
</Menu.LabelText>
|
||||||
|
<Menu.Group>
|
||||||
|
<Menu.Item
|
||||||
|
label={_(msg`Linear`)}
|
||||||
|
onPress={() => {
|
||||||
|
setTreeViewEnabled(false)
|
||||||
|
}}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Linear</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemRadio selected={!treeViewEnabled} />
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
label={_(msg`Threaded`)}
|
||||||
|
onPress={() => {
|
||||||
|
setTreeViewEnabled(true)
|
||||||
|
}}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Threaded</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemRadio selected={treeViewEnabled} />
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Group>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.LabelText>
|
||||||
|
<Trans>Reply sorting</Trans>
|
||||||
|
</Menu.LabelText>
|
||||||
|
<Menu.Group>
|
||||||
|
<Menu.Item
|
||||||
|
label={_(msg`Hot replies first`)}
|
||||||
|
onPress={() => {
|
||||||
|
setSortReplies('hotness')
|
||||||
|
}}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Hot replies first</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemRadio selected={sortReplies === 'hotness'} />
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
label={_(msg`Oldest replies first`)}
|
||||||
|
onPress={() => {
|
||||||
|
setSortReplies('oldest')
|
||||||
|
}}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Oldest replies first</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemRadio selected={sortReplies === 'oldest'} />
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
label={_(msg`Newest replies first`)}
|
||||||
|
onPress={() => {
|
||||||
|
setSortReplies('newest')
|
||||||
|
}}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Newest replies first</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemRadio selected={sortReplies === 'newest'} />
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
label={_(msg`Most-liked replies first`)}
|
||||||
|
onPress={() => {
|
||||||
|
setSortReplies('most-likes')
|
||||||
|
}}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Most-liked replies first</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemRadio selected={sortReplies === 'most-likes'} />
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
label={_(msg`Random (aka "Poster's Roulette")`)}
|
||||||
|
onPress={() => {
|
||||||
|
setSortReplies('random')
|
||||||
|
}}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Random (aka "Poster's Roulette")</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemRadio selected={sortReplies === 'random'} />
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Group>
|
||||||
|
</Menu.Outer>
|
||||||
|
</Menu.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ThreadMenu = memo(ThreadMenu)
|
||||||
|
|
||||||
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
|
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
|
||||||
const safeAreaInsets = useSafeAreaInsets()
|
const safeAreaInsets = useSafeAreaInsets()
|
||||||
const fabMinimalShellTransform = useMinimalShellFabTransform()
|
const fabMinimalShellTransform = useMinimalShellFabTransform()
|
||||||
|
|||||||
Reference in New Issue
Block a user