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) {
|
||||
const t = useTheme()
|
||||
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) {
|
||||
return children
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ export function ThreadPreferencesScreen({}: Props) {
|
||||
}
|
||||
style={[a.w_full, a.gap_md]}>
|
||||
<Toggle.LabelText style={[a.flex_1]}>
|
||||
<Trans>Show replies in a threaded view</Trans>
|
||||
<Trans>Show replies as threaded</Trans>
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Platform />
|
||||
</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 {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<string, number>())
|
||||
const [randomCache] = React.useState(() => new Map<string, number>())
|
||||
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 (
|
||||
<>
|
||||
<Header.Outer sticky={true} headerRef={headerRef}>
|
||||
<Header.Outer headerRef={headerRef}>
|
||||
<Header.BackButton />
|
||||
<Header.Content>
|
||||
<Header.TitleText>
|
||||
<Trans context="description">Post</Trans>
|
||||
</Header.TitleText>
|
||||
</Header.Content>
|
||||
<Header.Slot />
|
||||
<Header.Slot>
|
||||
<ThreadMenu
|
||||
sortReplies={sortReplies}
|
||||
treeViewEnabled={treeViewEnabled}
|
||||
setSortReplies={updateSortReplies}
|
||||
setTreeViewEnabled={updateTreeViewEnabled}
|
||||
/>
|
||||
</Header.Slot>
|
||||
</Header.Outer>
|
||||
|
||||
<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}) {
|
||||
const safeAreaInsets = useSafeAreaInsets()
|
||||
const fabMinimalShellTransform = useMinimalShellFabTransform()
|
||||
|
||||
Reference in New Issue
Block a user