diff options
Diffstat (limited to 'src/screens')
-rw-r--r-- | src/screens/PostThread/components/ThreadComposePrompt.tsx | 95 | ||||
-rw-r--r-- | src/screens/PostThread/components/ThreadItemAnchor.tsx | 4 | ||||
-rw-r--r-- | src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx | 139 | ||||
-rw-r--r-- | src/screens/PostThread/index.tsx | 6 | ||||
-rw-r--r-- | src/screens/Settings/ThreadPreferences.tsx | 158 | ||||
-rw-r--r-- | src/screens/VideoFeed/index.tsx | 4 |
6 files changed, 241 insertions, 165 deletions
diff --git a/src/screens/PostThread/components/ThreadComposePrompt.tsx b/src/screens/PostThread/components/ThreadComposePrompt.tsx new file mode 100644 index 000000000..e12c7e766 --- /dev/null +++ b/src/screens/PostThread/components/ThreadComposePrompt.tsx @@ -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> + ) +} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index fc1f1caeb..550bddc6a 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -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> diff --git a/src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx b/src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx new file mode 100644 index 000000000..d4cf120cf --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx @@ -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> + ) +} diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx index f91daf54b..7432f71db 100644 --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -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> ) } diff --git a/src/screens/Settings/ThreadPreferences.tsx b/src/screens/Settings/ThreadPreferences.tsx index af3cf915f..cba896a76 100644 --- a/src/screens/Settings/ThreadPreferences.tsx +++ b/src/screens/Settings/ThreadPreferences.tsx @@ -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> - ) -} diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx index b53593010..22989e6c2 100644 --- a/src/screens/VideoFeed/index.tsx +++ b/src/screens/VideoFeed/index.tsx @@ -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]} /> |