Threads v2 cleanup (#8902)

* Delete root PostThread component

* Remove PostThreadItem, migrate DebugMod to use new components

* Remove other unused components

* Move PostThreadFollowBtn to new home

* Move PostThreadComposePrompt to new home

* Remove gate

* Keep naming in DebugMod

* rm v1 prefs

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
Eric Bailey
2025-08-26 09:54:19 -05:00
committed by GitHub
parent e91a683810
commit df20ae237e
13 changed files with 40 additions and 2272 deletions
@@ -0,0 +1,95 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {useHaptics} from '#/lib/haptics'
import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf'
import {transparentifyColor} from '#/alf/util/colorGeneration'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text} from '#/components/Typography'
export function ThreadComposePrompt({
onPressCompose,
style,
}: {
onPressCompose: () => void
style?: StyleProp<ViewStyle>
}) {
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const t = useTheme()
const playHaptic = useHaptics()
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
useHideBottomBarBorderForScreen()
return (
<View
style={[
a.px_sm,
gtMobile
? [a.py_xs, a.border_t, t.atoms.border_contrast_low, t.atoms.bg]
: [a.pb_2xs],
style,
]}>
{!gtMobile && (
<LinearGradient
key={t.name} // android does not update when you change the colors. sigh.
start={[0.5, 0]}
end={[0.5, 1]}
colors={[
transparentifyColor(t.atoms.bg.backgroundColor, 0),
t.atoms.bg.backgroundColor,
]}
locations={[0.15, 0.4]}
style={[a.absolute, a.inset_0]}
/>
)}
<PressableScale
accessibilityRole="button"
accessibilityLabel={_(msg`Compose reply`)}
accessibilityHint={_(msg`Opens composer`)}
onPress={() => {
onPressCompose()
playHaptic('Light')
}}
onLongPress={ios(() => {
onPressCompose()
playHaptic('Heavy')
})}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
style={[
a.flex_row,
a.align_center,
a.p_sm,
a.gap_sm,
a.rounded_full,
(!gtMobile || hovered) && t.atoms.bg_contrast_25,
native([a.border, t.atoms.border_contrast_low]),
a.transition_color,
]}>
<UserAvatar
size={24}
avatar={profile?.avatar}
type={profile?.associated?.labeler ? 'labeler' : 'user'}
/>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans>Write your reply</Trans>
</Text>
</PressableScale>
</View>
)
}
@@ -32,9 +32,9 @@ import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {type PostSource} from '#/state/unstable-post-source'
import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
import {formatCount} from '#/view/com/util/numeric/format'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton'
import {
LINEAR_AVI_WIDTH,
OUTER_SPACE,
@@ -367,7 +367,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
</Link>
{showFollowButton && (
<View collapsable={false}>
<PostThreadFollowBtn did={post.author.did} />
<ThreadItemAnchorFollowButton did={post.author.did} />
</View>
)}
</View>
@@ -0,0 +1,139 @@
import React from 'react'
import {type AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {logger} from '#/logger'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {
useProfileFollowMutationQueue,
useProfileQuery,
} from '#/state/queries/profile'
import {useRequireAuth} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
export function ThreadItemAnchorFollowButton({did}: {did: string}) {
const {data: profile, isLoading} = useProfileQuery({did})
// We will never hit this - the profile will always be cached or loaded above
// but it keeps the typechecker happy
if (isLoading || !profile) return null
return <PostThreadFollowBtnLoaded profile={profile} />
}
function PostThreadFollowBtnLoaded({
profile: profileUnshadowed,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
}) {
const navigation = useNavigation()
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const profile = useProfileShadow(profileUnshadowed)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
'PostThreadItem',
)
const requireAuth = useRequireAuth()
const isFollowing = !!profile.viewer?.following
const isFollowedBy = !!profile.viewer?.followedBy
const [wasFollowing, setWasFollowing] = React.useState<boolean>(isFollowing)
// This prevents the button from disappearing as soon as we follow.
const showFollowBtn = React.useMemo(
() => !isFollowing || !wasFollowing,
[isFollowing, wasFollowing],
)
/**
* We want this button to stay visible even after following, so that the user can unfollow if they want.
* However, we need it to disappear after we push to a screen and then come back. We also need it to
* show up if we view the post while following, go to the profile and unfollow, then come back to the
* post.
*
* We want to update wasFollowing both on blur and on focus so that we hit all these cases. On native,
* we could do this only on focus because the transition animation gives us time to not notice the
* sudden rendering of the button. However, on web if we do this, there's an obvious flicker once the
* button renders. So, we update the state in both cases.
*/
React.useEffect(() => {
const updateWasFollowing = () => {
if (wasFollowing !== isFollowing) {
setWasFollowing(isFollowing)
}
}
const unsubscribeFocus = navigation.addListener('focus', updateWasFollowing)
const unsubscribeBlur = navigation.addListener('blur', updateWasFollowing)
return () => {
unsubscribeFocus()
unsubscribeBlur()
}
}, [isFollowing, wasFollowing, navigation])
const onPress = React.useCallback(() => {
if (!isFollowing) {
requireAuth(async () => {
try {
await queueFollow()
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to follow', {message: String(e)})
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
}
}
})
} else {
requireAuth(async () => {
try {
await queueUnfollow()
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unfollow', {message: String(e)})
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
}
}
})
}
}, [isFollowing, requireAuth, queueFollow, _, queueUnfollow])
if (!showFollowBtn) return null
return (
<Button
testID="followBtn"
label={_(msg`Follow ${profile.handle}`)}
onPress={onPress}
size="small"
variant="solid"
color={isFollowing ? 'secondary' : 'secondary_inverted'}
style={[a.rounded_full]}>
{gtMobile && (
<ButtonIcon
icon={isFollowing ? Check : Plus}
position="left"
size="sm"
/>
)}
<ButtonText>
{!isFollowing ? (
isFollowedBy ? (
<Trans>Follow back</Trans>
) : (
<Trans>Follow</Trans>
)
) : (
<Trans>Following</Trans>
)}
</ButtonText>
</Button>
)
}
+3 -3
View File
@@ -12,9 +12,9 @@ import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useUnstablePostSource} from '#/state/unstable-post-source'
import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
import {List, type ListMethods} from '#/view/com/util/List'
import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown'
import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt'
import {ThreadError} from '#/screens/PostThread/components/ThreadError'
import {
ThreadItemAnchor,
@@ -455,7 +455,7 @@ export function PostThread({uri}: {uri: string}) {
return (
<View>
{gtMobile && (
<PostThreadComposePrompt onPressCompose={onReplyToAnchor} />
<ThreadComposePrompt onPressCompose={onReplyToAnchor} />
)}
</View>
)
@@ -586,7 +586,7 @@ function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
return (
<Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
<PostThreadComposePrompt onPressCompose={onPressReply} />
<ThreadComposePrompt onPressCompose={onPressReply} />
</Animated.View>
)
}
-158
View File
@@ -6,11 +6,6 @@ import {
type CommonNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {
usePreferencesQuery,
useSetThreadViewPreferencesMutation,
} from '#/state/queries/preferences'
import {
normalizeSort,
normalizeView,
@@ -18,7 +13,6 @@ import {
} from '#/state/queries/preferences/useThreadPreferences'
import {atoms as a, useTheme} from '#/alf'
import * as Toggle from '#/components/forms/Toggle'
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import {Tree_Stroke2_Corner0_Rounded as TreeIcon} from '#/components/icons/Tree'
@@ -28,16 +22,6 @@ import * as SettingsList from './components/SettingsList'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'>
export function ThreadPreferencesScreen({}: Props) {
const gate = useGate()
return gate('post_threads_v2_unspecced') ? (
<ThreadPreferencesV2 />
) : (
<ThreadPreferencesV1 />
)
}
export function ThreadPreferencesV2() {
const t = useTheme()
const {_} = useLingui()
const {
@@ -150,145 +134,3 @@ export function ThreadPreferencesV2() {
</Layout.Screen>
)
}
export function ThreadPreferencesV1() {
const {_} = useLingui()
const t = useTheme()
const {data: preferences} = usePreferencesQuery()
const {mutate: setThreadViewPrefs, variables} =
useSetThreadViewPreferencesMutation()
const sortReplies = variables?.sort ?? preferences?.threadViewPrefs?.sort
const prioritizeFollowedUsers = Boolean(
variables?.prioritizeFollowedUsers ??
preferences?.threadViewPrefs?.prioritizeFollowedUsers,
)
const treeViewEnabled = Boolean(
variables?.lab_treeViewEnabled ??
preferences?.threadViewPrefs?.lab_treeViewEnabled,
)
return (
<Layout.Screen testID="threadPreferencesScreen">
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Thread Preferences</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<SettingsList.Container>
<SettingsList.Group>
<SettingsList.ItemIcon icon={BubblesIcon} />
<SettingsList.ItemText>
<Trans>Sort replies</Trans>
</SettingsList.ItemText>
<View style={[a.w_full, a.gap_md]}>
<Text style={[a.flex_1, t.atoms.text_contrast_medium]}>
<Trans>Sort replies to the same post by:</Trans>
</Text>
<Toggle.Group
label={_(msg`Sort replies by`)}
type="radio"
values={sortReplies ? [sortReplies] : []}
onChange={values => setThreadViewPrefs({sort: values[0]})}>
<View style={[a.gap_sm, a.flex_1]}>
<Toggle.Item name="hotness" label={_(msg`Hot replies first`)}>
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Hot replies first</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item
name="oldest"
label={_(msg`Oldest replies first`)}>
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Oldest replies first</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item
name="newest"
label={_(msg`Newest replies first`)}>
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Newest replies first</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item
name="most-likes"
label={_(msg`Most-liked replies first`)}>
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Most-liked first</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item
name="random"
label={_(msg`Random (aka "Poster's Roulette")`)}>
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Random (aka "Poster's Roulette")</Trans>
</Toggle.LabelText>
</Toggle.Item>
</View>
</Toggle.Group>
</View>
</SettingsList.Group>
<SettingsList.Group>
<SettingsList.ItemIcon icon={PersonGroupIcon} />
<SettingsList.ItemText>
<Trans>Prioritize your Follows</Trans>
</SettingsList.ItemText>
<Toggle.Item
type="checkbox"
name="prioritize-follows"
label={_(msg`Prioritize your Follows`)}
value={prioritizeFollowedUsers}
onChange={value =>
setThreadViewPrefs({
prioritizeFollowedUsers: value,
})
}
style={[a.w_full, a.gap_md]}>
<Toggle.LabelText style={[a.flex_1]}>
<Trans>
Show replies by people you follow before all other replies
</Trans>
</Toggle.LabelText>
<Toggle.Platform />
</Toggle.Item>
</SettingsList.Group>
<SettingsList.Divider />
<SettingsList.Group>
<SettingsList.ItemIcon icon={BeakerIcon} />
<SettingsList.ItemText>
<Trans>Experimental</Trans>
</SettingsList.ItemText>
<Toggle.Item
type="checkbox"
name="threaded-mode"
label={_(msg`Threaded mode`)}
value={treeViewEnabled}
onChange={value =>
setThreadViewPrefs({
lab_treeViewEnabled: value,
})
}
style={[a.w_full, a.gap_md]}>
<Toggle.LabelText style={[a.flex_1]}>
<Trans>Show replies as threaded</Trans>
</Toggle.LabelText>
<Toggle.Platform />
</Toggle.Item>
</SettingsList.Group>
</SettingsList.Container>
</Layout.Content>
</Layout.Screen>
)
}
+2 -2
View File
@@ -80,9 +80,9 @@ import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell'
import {useSetLightStatusBar} from '#/state/shell/light-status-bar'
import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
import {List} from '#/view/com/util/List'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt'
import {Header} from '#/screens/VideoFeed/components/Header'
import {atoms as a, ios, platform, ThemeProvider, useTheme} from '#/alf'
import {setSystemUITheme} from '#/alf/util/systemUI'
@@ -883,7 +883,7 @@ function Overlay({
player={player}
seekingAnimationSV={seekingAnimationSV}
scrollGesture={scrollGesture}>
<PostThreadComposePrompt
<ThreadComposePrompt
onPressCompose={onPressReply}
style={[a.pt_md, a.pb_sm]}
/>