Skip to content

Commit

Permalink
Add reply sorting in-thread (#7156)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
gaearon and estrattonbailey authored Dec 18, 2024
1 parent 4097fe0 commit a7f763f
Show file tree
Hide file tree
Showing 4 changed files with 281 additions and 13 deletions.
49 changes: 49 additions & 0 deletions src/components/Menu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
51 changes: 51 additions & 0 deletions src/components/Menu/index.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion src/screens/Settings/ThreadPreferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
192 changes: 180 additions & 12 deletions src/view/com/post-thread/PostThread.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -198,7 +241,9 @@ export function PostThread({uri}: {uri: string | undefined}) {
)
}, [
thread,
preferences?.threadViewPrefs,
prioritizeFollowedUsers,
sortReplies,
treeViewEnabled,
currentDid,
treeView,
threadModerationCache,
Expand Down Expand Up @@ -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}>
Expand Down Expand Up @@ -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()
Expand Down

0 comments on commit a7f763f

Please sign in to comment.