diff options
Diffstat (limited to 'src/screens')
29 files changed, 1532 insertions, 412 deletions
diff --git a/src/screens/Login/ForgotPasswordForm.tsx b/src/screens/Login/ForgotPasswordForm.tsx index e8582f46f..d3b5a4f10 100644 --- a/src/screens/Login/ForgotPasswordForm.tsx +++ b/src/screens/Login/ForgotPasswordForm.tsx @@ -1,7 +1,6 @@ import React, {useState} from 'react' import {ActivityIndicator, Keyboard, View} from 'react-native' -import {ComAtprotoServerDescribeServer} from '@atproto/api' -import {BskyAgent} from '@atproto/api' +import {type ComAtprotoServerDescribeServer} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import * as EmailValidator from 'email-validator' @@ -9,6 +8,7 @@ import * as EmailValidator from 'email-validator' import {isNetworkError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' +import {Agent} from '#/state/session/agent' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {FormError} from '#/components/forms/FormError' @@ -55,7 +55,7 @@ export const ForgotPasswordForm = ({ setIsProcessing(true) try { - const agent = new BskyAgent({service: serviceUrl}) + const agent = new Agent(null, {service: serviceUrl}) await agent.com.atproto.server.requestPasswordReset({email}) onEmailSent() } catch (e: any) { diff --git a/src/screens/Login/SetNewPasswordForm.tsx b/src/screens/Login/SetNewPasswordForm.tsx index d2fa0f9c1..be72b558b 100644 --- a/src/screens/Login/SetNewPasswordForm.tsx +++ b/src/screens/Login/SetNewPasswordForm.tsx @@ -1,6 +1,5 @@ import {useState} from 'react' import {ActivityIndicator, View} from 'react-native' -import {BskyAgent} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -9,6 +8,7 @@ import {isNetworkError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors' import {checkAndFormatResetCode} from '#/lib/strings/password' import {logger} from '#/logger' +import {Agent} from '#/state/session/agent' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {FormError} from '#/components/forms/FormError' @@ -63,7 +63,7 @@ export const SetNewPasswordForm = ({ setIsProcessing(true) try { - const agent = new BskyAgent({service: serviceUrl}) + const agent = new Agent(null, {service: serviceUrl}) await agent.com.atproto.server.resetPassword({ token: formattedCode, password, diff --git a/src/screens/Moderation/VerificationSettings.tsx b/src/screens/Moderation/VerificationSettings.tsx index cd023ae56..aec70cf85 100644 --- a/src/screens/Moderation/VerificationSettings.tsx +++ b/src/screens/Moderation/VerificationSettings.tsx @@ -44,7 +44,12 @@ export function Screen() { <InlineLinkText overridePresentation to={urls.website.blog.initialVerificationAnnouncement} - label={_(msg`Learn more`)} + label={_( + msg({ + message: `Learn more`, + context: `english-only-resource`, + }), + )} onPress={() => { logger.metric( 'verification:learn-more', diff --git a/src/screens/Onboarding/Layout.tsx b/src/screens/Onboarding/Layout.tsx index 16c37358f..6394d9c96 100644 --- a/src/screens/Onboarding/Layout.tsx +++ b/src/screens/Onboarding/Layout.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useState} from 'react' import {ScrollView, View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {msg} from '@lingui/macro' @@ -11,20 +11,23 @@ import { atoms as a, flatten, native, - TextStyleProp, + type TextStyleProp, + tokens, useBreakpoints, useTheme, web, } from '#/alf' import {leading} from '#/alf/typography' import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' +import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow' +import {HEADER_SLOT_SIZE} from '#/components/Layout' import {createPortalGroup} from '#/components/Portal' import {P, Text} from '#/components/Typography' -const COL_WIDTH = 420 +const ONBOARDING_COL_WIDTH = 420 export const OnboardingControls = createPortalGroup() +export const OnboardingHeaderSlot = createPortalGroup() export function Layout({children}: React.PropsWithChildren<{}>) { const {_} = useLingui() @@ -46,6 +49,8 @@ export function Layout({children}: React.PropsWithChildren<{}>) { const paddingTop = gtMobile ? a.py_5xl : a.py_lg const dialogLabel = _(msg`Set up your account`) + const [footerHeight, setFooterHeight] = useState(0) + return ( <View aria-modal @@ -62,45 +67,67 @@ export function Layout({children}: React.PropsWithChildren<{}>) { t.atoms.bg, ]}> {__DEV__ && ( - <View style={[a.absolute, a.p_xl, a.z_10, {right: 0, top: insets.top}]}> - <Button - variant="ghost" - color="negative" - size="small" - onPress={() => onboardDispatch({type: 'skip'})} - // DEV ONLY - label="Clear onboarding state"> - <ButtonText>Clear</ButtonText> - </Button> - </View> + <Button + variant="ghost" + color="negative" + size="tiny" + onPress={() => onboardDispatch({type: 'skip'})} + // DEV ONLY + label="Clear onboarding state" + style={[ + a.absolute, + a.z_10, + { + left: '50%', + top: insets.top + 2, + transform: [{translateX: '-50%'}], + }, + ]}> + <ButtonText>[DEV] Clear</ButtonText> + </Button> )} - {!gtMobile && state.hasPrev && ( + {!gtMobile && ( <View + pointerEvents="box-none" style={[ web(a.fixed), native(a.absolute), + a.left_0, + a.right_0, a.flex_row, a.w_full, a.justify_center, a.z_20, a.px_xl, - { - top: paddingTop.paddingTop + insets.top - 1, - }, + {top: paddingTop.paddingTop + insets.top - 1}, ]}> - <View style={[a.w_full, a.align_start, {maxWidth: COL_WIDTH}]}> - <Button - key={state.activeStep} // remove focus state on nav - variant="ghost" - color="secondary" - size="small" - shape="round" - label={_(msg`Go back to previous step`)} - style={[a.absolute]} - onPress={() => dispatch({type: 'prev'})}> - <ButtonIcon icon={ChevronLeft} /> - </Button> + <View + pointerEvents="box-none" + style={[ + a.w_full, + a.align_start, + a.flex_row, + a.justify_between, + {maxWidth: ONBOARDING_COL_WIDTH}, + ]}> + {state.hasPrev ? ( + <Button + key={state.activeStep} // remove focus state on nav + color="secondary" + variant="ghost" + shape="square" + size="small" + label={_(msg`Go back to previous step`)} + onPress={() => dispatch({type: 'prev'})} + style={[a.bg_transparent]}> + <ButtonIcon icon={ArrowLeft} size="lg" /> + </Button> + ) : ( + <View /> + )} + + <OnboardingHeaderSlot.Outlet /> </View> </View> )} @@ -109,22 +136,24 @@ export function Layout({children}: React.PropsWithChildren<{}>) { ref={scrollview} style={[a.h_full, a.w_full, {paddingTop: insets.top}]} contentContainerStyle={{borderWidth: 0}} - // @ts-ignore web only --prf + scrollIndicatorInsets={{bottom: footerHeight - insets.bottom}} + // @ts-expect-error web only --prf dataSet={{'stable-gutters': 1}}> <View style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}> - <View style={[a.flex_1, {maxWidth: COL_WIDTH}]}> + <View style={[a.flex_1, {maxWidth: ONBOARDING_COL_WIDTH}]}> <View style={[a.w_full, a.align_center, paddingTop]}> <View style={[ a.flex_row, a.gap_sm, a.w_full, - {paddingTop: 17, maxWidth: '60%'}, + a.align_center, + {height: HEADER_SLOT_SIZE, maxWidth: '60%'}, ]}> {Array(state.totalSteps) .fill(0) - .map((_, i) => ( + .map((__, i) => ( <View key={i} style={[ @@ -144,19 +173,16 @@ export function Layout({children}: React.PropsWithChildren<{}>) { </View> </View> - <View - style={[a.w_full, a.mb_5xl, {paddingTop: gtMobile ? 20 : 40}]}> - {children} - </View> + <View style={[a.w_full, a.mb_5xl, a.pt_md]}>{children}</View> - <View style={{height: 400}} /> + <View style={{height: 100 + footerHeight}} /> </View> </View> </ScrollView> <View + onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)} style={[ - // @ts-ignore web only -prf isWeb ? a.fixed : a.absolute, {bottom: 0, left: 0, right: 0}, t.atoms.bg, @@ -167,30 +193,30 @@ export function Layout({children}: React.PropsWithChildren<{}>) { isWeb ? a.py_2xl : { - paddingTop: a.pt_lg.paddingTop, - paddingBottom: insets.bottom + 10, + paddingTop: tokens.space.md, + paddingBottom: insets.bottom + tokens.space.md, }, ]}> <View style={[ a.w_full, - {maxWidth: COL_WIDTH}, - gtMobile && [a.flex_row, a.justify_between], + {maxWidth: ONBOARDING_COL_WIDTH}, + gtMobile && [a.flex_row, a.justify_between, a.align_center], ]}> {gtMobile && (state.hasPrev ? ( <Button key={state.activeStep} // remove focus state on nav - variant="solid" color="secondary" - size="large" - shape="round" + variant="ghost" + shape="square" + size="small" label={_(msg`Go back to previous step`)} onPress={() => dispatch({type: 'prev'})}> - <ButtonIcon icon={ChevronLeft} /> + <ButtonIcon icon={ArrowLeft} size="lg" /> </Button> ) : ( - <View style={{height: 54}} /> + <View style={{height: 33}} /> ))} <OnboardingControls.Outlet /> </View> diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index 54d282a5e..c4b723ce1 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -1,5 +1,12 @@ -import React from 'react' +import {useCallback, useContext, useState} from 'react' import {View} from 'react-native' +import Animated, { + Easing, + LayoutAnimationConfig, + SlideInRight, + SlideOutLeft, +} from 'react-native-reanimated' +import {Image} from 'expo-image' import { type AppBskyActorDefs, type AppBskyActorProfile, @@ -22,6 +29,7 @@ import { import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {logEvent, useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' +import {isNative} from '#/platform/detection' import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' import {getAllListMembers} from '#/state/queries/list-members' import {preferencesQueryKey} from '#/state/queries/preferences' @@ -36,13 +44,22 @@ import { import { DescriptionText, OnboardingControls, + OnboardingHeaderSlot, TitleText, } from '#/screens/Onboarding/Layout' -import {Context} from '#/screens/Onboarding/state' +import {Context, type OnboardingState} from '#/screens/Onboarding/state' import {bulkWriteFollows} from '#/screens/Onboarding/util' -import {atoms as a, useTheme} from '#/alf' +import { + atoms as a, + native, + platform, + tokens, + useBreakpoints, + useTheme, +} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {IconCircle} from '#/components/IconCircle' +import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2' @@ -53,10 +70,9 @@ import * as bsky from '#/types/bsky' export function StepFinished() { const {_} = useLingui() - const t = useTheme() - const {state, dispatch} = React.useContext(Context) + const {state, dispatch} = useContext(Context) const onboardDispatch = useOnboardingDispatch() - const [saving, setSaving] = React.useState(false) + const [saving, setSaving] = useState(false) const queryClient = useQueryClient() const agent = useAgent() const requestNotificationsPermission = useRequestNotificationsPermission() @@ -66,7 +82,7 @@ export function StepFinished() { const {startProgressGuide} = useProgressGuideControls() const gate = useGate() - const finishOnboarding = React.useCallback(async () => { + const finishOnboarding = useCallback(async () => { setSaving(true) let starterPack: AppBskyGraphDefs.StarterPackView | undefined @@ -245,6 +261,267 @@ export function StepFinished() { gate, ]) + return state.experiments?.onboarding_value_prop ? ( + <ValueProposition + finishOnboarding={finishOnboarding} + saving={saving} + state={state} + /> + ) : ( + <LegacyFinalStep + finishOnboarding={finishOnboarding} + saving={saving} + state={state} + /> + ) +} + +const PROP_1 = { + light: platform({ + native: require('../../../assets/images/onboarding/value_prop_1_light.webp'), + web: require('../../../assets/images/onboarding/value_prop_1_light_borderless.webp'), + }), + dim: platform({ + native: require('../../../assets/images/onboarding/value_prop_1_dim.webp'), + web: require('../../../assets/images/onboarding/value_prop_1_dim_borderless.webp'), + }), + dark: platform({ + native: require('../../../assets/images/onboarding/value_prop_1_dark.webp'), + web: require('../../../assets/images/onboarding/value_prop_1_dark_borderless.webp'), + }), +} as const + +const PROP_2 = { + light: require('../../../assets/images/onboarding/value_prop_2_light.webp'), + dim: require('../../../assets/images/onboarding/value_prop_2_dim.webp'), + dark: require('../../../assets/images/onboarding/value_prop_2_dark.webp'), +} as const + +const PROP_3 = { + light: require('../../../assets/images/onboarding/value_prop_3_light.webp'), + dim: require('../../../assets/images/onboarding/value_prop_3_dim.webp'), + dark: require('../../../assets/images/onboarding/value_prop_3_dark.webp'), +} as const + +function ValueProposition({ + finishOnboarding, + saving, + state, +}: { + finishOnboarding: () => void + saving: boolean + state: OnboardingState +}) { + const [subStep, setSubStep] = useState<0 | 1 | 2>(0) + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + + const image = [PROP_1[t.name], PROP_2[t.name], PROP_3[t.name]][subStep] + + const onPress = () => { + if (subStep === 2) { + finishOnboarding() // has its own metrics + } else if (subStep === 1) { + setSubStep(2) + logger.metric('onboarding:valueProp:stepTwo:nextPressed', {}) + } else if (subStep === 0) { + setSubStep(1) + logger.metric('onboarding:valueProp:stepOne:nextPressed', {}) + } + } + + const {title, description, alt} = [ + { + title: _(msg`Free your feed`), + description: _( + msg`No more doomscrolling junk-filled algorithms. Find feeds that work for you, not against you.`, + ), + alt: _( + msg`A collection of popular feeds you can find on Bluesky, including News, Booksky, Game Dev, Blacksky, and Fountain Pens`, + ), + }, + { + title: _(msg`Find your people`), + description: _( + msg`Ditch the trolls and clickbait. Find real people and conversations that matter to you.`, + ), + alt: _( + msg`Your profile picture surrounded by concentric circles of other users' profile pictures`, + ), + }, + { + title: _(msg`Forget the noise`), + description: _( + msg`No ads, no invasive tracking, no engagement traps. Bluesky respects your time and attention.`, + ), + alt: _( + msg`An illustration of several Bluesky posts alongside repost, like, and comment icons`, + ), + }, + ][subStep] + + return ( + <> + {!gtMobile && ( + <OnboardingHeaderSlot.Portal> + <Button + disabled={saving} + variant="ghost" + color="secondary" + size="small" + label={_(msg`Skip introduction and start using your account`)} + onPress={() => { + logger.metric('onboarding:valueProp:skipPressed', {}) + finishOnboarding() + }} + style={[a.bg_transparent]}> + <ButtonText> + <Trans>Skip</Trans> + </ButtonText> + </Button> + </OnboardingHeaderSlot.Portal> + )} + + <LayoutAnimationConfig skipEntering skipExiting> + <Animated.View + key={subStep} + entering={native( + SlideInRight.easing(Easing.out(Easing.exp)).duration(500), + )} + exiting={native( + SlideOutLeft.easing(Easing.out(Easing.exp)).duration(500), + )}> + <View + style={[ + a.relative, + a.align_center, + a.justify_center, + isNative && {marginHorizontal: tokens.space.xl * -1}, + a.pointer_events_none, + ]}> + <Image + source={image} + style={[a.w_full, {aspectRatio: 1}]} + alt={alt} + accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background + /> + {subStep === 1 && ( + <Image + source={state.profileStepResults.imageUri} + style={[ + a.z_10, + a.absolute, + a.rounded_full, + { + width: `${(80 / 393) * 100}%`, + height: `${(80 / 393) * 100}%`, + }, + ]} + accessibilityIgnoresInvertColors + alt={_(msg`Your profile picture`)} + /> + )} + </View> + + <View style={[a.mt_4xl, a.gap_2xl, a.align_center]}> + <View style={[a.flex_row, a.gap_sm]}> + <Dot active={subStep === 0} /> + <Dot active={subStep === 1} /> + <Dot active={subStep === 2} /> + </View> + + <View style={[a.gap_sm]}> + <Text style={[a.font_heavy, a.text_3xl, a.text_center]}> + {title} + </Text> + <Text + style={[ + t.atoms.text_contrast_medium, + a.text_md, + a.leading_snug, + a.text_center, + ]}> + {description} + </Text> + </View> + </View> + </Animated.View> + </LayoutAnimationConfig> + + <OnboardingControls.Portal> + <View style={gtMobile && [a.gap_md, a.flex_row]}> + {gtMobile && ( + <Button + disabled={saving} + color="secondary" + size="large" + label={_(msg`Skip introduction and start using your account`)} + onPress={() => finishOnboarding()}> + <ButtonText> + <Trans>Skip</Trans> + </ButtonText> + </Button> + )} + <Button + disabled={saving} + key={state.activeStep} // remove focus state on nav + color="primary" + size="large" + label={ + subStep === 2 + ? _(msg`Complete onboarding and start using your account`) + : _(msg`Next`) + } + onPress={onPress}> + <ButtonText> + {saving ? ( + <Trans>Finalizing</Trans> + ) : subStep === 2 ? ( + <Trans>Let's go!</Trans> + ) : ( + <Trans>Next</Trans> + )} + </ButtonText> + {subStep === 2 && ( + <ButtonIcon icon={saving ? Loader : ArrowRight} /> + )} + </Button> + </View> + </OnboardingControls.Portal> + </> + ) +} + +function Dot({active}: {active: boolean}) { + const t = useTheme() + const {_} = useLingui() + + return ( + <View + style={[ + a.rounded_full, + {width: 8, height: 8}, + active + ? {backgroundColor: t.palette.primary_500} + : t.atoms.bg_contrast_50, + ]} + /> + ) +} + +function LegacyFinalStep({ + finishOnboarding, + saving, + state, +}: { + finishOnboarding: () => void + saving: boolean + state: OnboardingState +}) { + const t = useTheme() + const {_} = useLingui() + return ( <View style={[a.align_start]}> <IconCircle icon={Check} style={[a.mb_2xl]} /> @@ -303,9 +580,9 @@ export function StepFinished() { <OnboardingControls.Portal> <Button + testID="onboardingFinish" disabled={saving} key={state.activeStep} // remove focus state on nav - variant="solid" color="primary" size="large" label={_(msg`Complete onboarding and start using your account`)} diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx index 2a121cac6..d214937d4 100644 --- a/src/screens/Onboarding/StepInterests/index.tsx +++ b/src/screens/Onboarding/StepInterests/index.tsx @@ -160,7 +160,16 @@ export function StepInterests() { <View style={[a.w_full, a.pt_2xl]}> {isLoading ? ( - <Loader size="xl" /> + <View + style={[ + a.flex_1, + a.mt_md, + a.align_center, + a.justify_center, + {minHeight: 400}, + ]}> + <Loader size="xl" /> + </View> ) : isError || !data ? ( <View style={[ @@ -235,6 +244,7 @@ export function StepInterests() { ) : ( <Button disabled={saving || !data} + testID="onboardingContinue" variant="solid" color="primary" size="large" diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index 30da5cbb5..6066e4297 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -4,7 +4,7 @@ import {Image as ExpoImage} from 'expo-image' import { type ImagePickerOptions, launchImageLibraryAsync, - MediaTypeOptions, + UIImagePickerPreferredAssetRepresentationMode, } from 'expo-image-picker' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -97,10 +97,12 @@ export function StepProfile() { const response = await sheetWrapper( launchImageLibraryAsync({ exif: false, - mediaTypes: MediaTypeOptions.Images, + mediaTypes: ['images'], quality: 1, ...opts, legacy: true, + preferredAssetRepresentationMode: + UIImagePickerPreferredAssetRepresentationMode.Automatic, }), ) @@ -266,8 +268,9 @@ export function StepProfile() { </View> <OnboardingControls.Portal> - <View style={[a.gap_md, gtMobile && {flexDirection: 'row-reverse'}]}> + <View style={[a.gap_md, gtMobile && a.flex_row_reverse]}> <Button + testID="onboardingContinue" variant="solid" color="primary" size="large" @@ -279,6 +282,7 @@ export function StepProfile() { <ButtonIcon icon={ChevronRight} position="right" /> </Button> <Button + testID="onboardingAvatarCreator" variant="ghost" color="primary" size="large" diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx new file mode 100644 index 000000000..5a9d3464c --- /dev/null +++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx @@ -0,0 +1,356 @@ +import {useCallback, useContext, useMemo, useState} from 'react' +import {View} from 'react-native' +import {type ModerationOpts} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation, useQueryClient} from '@tanstack/react-query' +import * as bcp47Match from 'bcp-47-match' + +import {wait} from '#/lib/async/wait' +import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {updateProfileShadow} from '#/state/cache/profile-shadow' +import {useLanguagePrefs} from '#/state/preferences' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useAgent, useSession} from '#/state/session' +import {useOnboardingDispatch} from '#/state/shell' +import {OnboardingControls} from '#/screens/Onboarding/Layout' +import { + Context, + popularInterests, + useInterestsDisplayNames, +} from '#/screens/Onboarding/state' +import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers' +import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise' +import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' +import {boostInterests, InterestTabs} from '#/components/InterestTabs' +import {Loader} from '#/components/Loader' +import * as ProfileCard from '#/components/ProfileCard' +import * as toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' +import {bulkWriteFollows} from '../util' + +export function StepSuggestedAccounts() { + const {_} = useLingui() + const t = useTheme() + const {gtMobile} = useBreakpoints() + const moderationOpts = useModerationOpts() + const agent = useAgent() + const {currentAccount} = useSession() + const queryClient = useQueryClient() + + const {state, dispatch} = useContext(Context) + const onboardDispatch = useOnboardingDispatch() + + const [selectedInterest, setSelectedInterest] = useState<string | null>(null) + // keeping track of who was followed via the follow all button + // so we can enable/disable the button without having to dig through the shadow cache + const [followedUsers, setFollowedUsers] = useState<string[]>([]) + + /* + * Special language handling copied wholesale from the Explore screen + */ + const {contentLanguages} = useLanguagePrefs() + const useFullExperience = useMemo(() => { + if (contentLanguages.length === 0) return true + return bcp47Match.basicFilter('en', contentLanguages).length > 0 + }, [contentLanguages]) + const interestsDisplayNames = useInterestsDisplayNames() + const interests = Object.keys(interestsDisplayNames) + .sort(boostInterests(popularInterests)) + .sort(boostInterests(state.interestsStepResults.selectedInterests)) + const { + data: suggestedUsers, + isLoading, + error, + isRefetching, + refetch, + } = useSuggestedUsers({ + category: selectedInterest || (useFullExperience ? null : interests[0]), + search: !useFullExperience, + overrideInterests: state.interestsStepResults.selectedInterests, + }) + + const isError = !!error + + const skipOnboarding = useCallback(() => { + onboardDispatch({type: 'finish'}) + dispatch({type: 'finish'}) + }, [onboardDispatch, dispatch]) + + const followableDids = + suggestedUsers?.actors + .filter( + user => + user.did !== currentAccount?.did && + !isBlockedOrBlocking(user) && + !isMuted(user) && + !user.viewer?.following && + !followedUsers.includes(user.did), + ) + .map(user => user.did) ?? [] + + const {mutate: followAll, isPending: isFollowingAll} = useMutation({ + onMutate: () => { + logger.metric('onboarding:suggestedAccounts:followAllPressed', { + tab: selectedInterest ?? 'all', + numAccounts: followableDids.length, + }) + }, + mutationFn: async () => { + for (const did of followableDids) { + updateProfileShadow(queryClient, did, { + followingUri: 'pending', + }) + } + const uris = await wait(1e3, bulkWriteFollows(agent, followableDids)) + for (const did of followableDids) { + const uri = uris.get(did) + updateProfileShadow(queryClient, did, { + followingUri: uri, + }) + } + return followableDids + }, + onSuccess: newlyFollowed => { + toast.show(_(msg`Followed all accounts!`), {type: 'success'}) + setFollowedUsers(followed => [...followed, ...newlyFollowed]) + }, + onError: () => { + toast.show( + _(msg`Failed to follow all suggested accounts, please try again`), + {type: 'error'}, + ) + }, + }) + + const canFollowAll = followableDids.length > 0 && !isFollowingAll + + return ( + <View style={[a.align_start]} testID="onboardingInterests"> + <Text style={[a.font_heavy, a.text_3xl]}> + <Trans comment="Accounts suggested to the user for them to follow"> + Suggested for you + </Trans> + </Text> + + <View + style={[ + a.overflow_hidden, + a.mt_lg, + isWeb ? a.max_w_full : {marginHorizontal: tokens.space.xl * -1}, + a.flex_1, + a.justify_start, + ]}> + <TabBar + selectedInterest={selectedInterest} + onSelectInterest={setSelectedInterest} + defaultTabLabel={_( + msg({ + message: 'All', + comment: 'the default tab in the interests tab bar', + }), + )} + selectedInterests={state.interestsStepResults.selectedInterests} + /> + + {isLoading || !moderationOpts ? ( + <View + style={[ + a.flex_1, + a.mt_md, + a.align_center, + a.justify_center, + {minHeight: 400}, + ]}> + <Loader size="xl" /> + </View> + ) : isError ? ( + <View style={[a.flex_1, a.px_xl, a.pt_5xl]}> + <Admonition type="error"> + <Trans> + An error occurred while fetching suggested accounts. + </Trans> + </Admonition> + </View> + ) : ( + <View + style={[ + a.flex_1, + a.mt_md, + a.border_y, + t.atoms.border_contrast_low, + isWeb && [a.border_x, a.rounded_sm, a.overflow_hidden], + ]}> + {suggestedUsers?.actors.map((user, index) => ( + <SuggestedProfileCard + key={user.did} + profile={user} + moderationOpts={moderationOpts} + position={index} + /> + ))} + </View> + )} + </View> + + <OnboardingControls.Portal> + {isError ? ( + <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}> + <Button + disabled={isRefetching} + color="secondary" + size="large" + label={_(msg`Retry`)} + onPress={() => refetch()}> + <ButtonText> + <Trans>Retry</Trans> + </ButtonText> + <ButtonIcon icon={ArrowRotateCounterClockwiseIcon} /> + </Button> + <Button + color="secondary" + size="large" + label={_(msg`Skip this flow`)} + onPress={skipOnboarding}> + <ButtonText> + <Trans>Skip</Trans> + </ButtonText> + </Button> + </View> + ) : ( + <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}> + <Button + disabled={!canFollowAll} + color="secondary" + size="large" + label={_(msg`Follow all accounts`)} + onPress={() => followAll()}> + <ButtonText> + <Trans>Follow all</Trans> + </ButtonText> + <ButtonIcon icon={isFollowingAll ? Loader : PlusIcon} /> + </Button> + <Button + disabled={isFollowingAll} + color="primary" + size="large" + label={_(msg`Continue to next step`)} + onPress={() => dispatch({type: 'next'})}> + <ButtonText> + <Trans>Continue</Trans> + </ButtonText> + </Button> + </View> + )} + </OnboardingControls.Portal> + </View> + ) +} + +function TabBar({ + selectedInterest, + onSelectInterest, + selectedInterests, + hideDefaultTab, + defaultTabLabel, +}: { + selectedInterest: string | null + onSelectInterest: (interest: string | null) => void + selectedInterests: string[] + hideDefaultTab?: boolean + defaultTabLabel?: string +}) { + const {_} = useLingui() + const interestsDisplayNames = useInterestsDisplayNames() + const interests = Object.keys(interestsDisplayNames) + .sort(boostInterests(popularInterests)) + .sort(boostInterests(selectedInterests)) + + return ( + <InterestTabs + interests={hideDefaultTab ? interests : ['all', ...interests]} + selectedInterest={ + selectedInterest || (hideDefaultTab ? interests[0] : 'all') + } + onSelectTab={tab => { + logger.metric( + 'onboarding:suggestedAccounts:tabPressed', + {tab: tab}, + {statsig: true}, + ) + onSelectInterest(tab === 'all' ? null : tab) + }} + interestsDisplayNames={ + hideDefaultTab + ? interestsDisplayNames + : { + all: defaultTabLabel || _(msg`For You`), + ...interestsDisplayNames, + } + } + gutterWidth={isWeb ? 0 : tokens.space.xl} + /> + ) +} + +function SuggestedProfileCard({ + profile, + moderationOpts, + position, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts: ModerationOpts + position: number +}) { + const t = useTheme() + return ( + <View + style={[ + a.flex_1, + a.w_full, + a.py_lg, + a.px_xl, + position !== 0 && a.border_t, + t.atoms.border_contrast_low, + ]}> + <ProfileCard.Outer> + <ProfileCard.Header> + <ProfileCard.Avatar + profile={profile} + moderationOpts={moderationOpts} + disabledPreview + /> + <ProfileCard.NameAndHandle + profile={profile} + moderationOpts={moderationOpts} + /> + <ProfileCard.FollowButton + profile={profile} + moderationOpts={moderationOpts} + withIcon={false} + logContext="OnboardingSuggestedAccounts" + onFollow={() => { + logger.metric( + 'suggestedUser:follow', + { + logContext: 'Onboarding', + location: 'Card', + recId: undefined, + position, + }, + {statsig: true}, + ) + }} + /> + </ProfileCard.Header> + <ProfileCard.Description profile={profile} numberOfLines={3} /> + </ProfileCard.Outer> + </View> + ) +} diff --git a/src/screens/Onboarding/index.tsx b/src/screens/Onboarding/index.tsx index a5c423ca1..f13402ece 100644 --- a/src/screens/Onboarding/index.tsx +++ b/src/screens/Onboarding/index.tsx @@ -1,21 +1,37 @@ -import React from 'react' +import {useMemo, useReducer} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout' +import {useGate} from '#/lib/statsig/statsig' +import { + Layout, + OnboardingControls, + OnboardingHeaderSlot, +} from '#/screens/Onboarding/Layout' import {Context, initialState, reducer} from '#/screens/Onboarding/state' import {StepFinished} from '#/screens/Onboarding/StepFinished' import {StepInterests} from '#/screens/Onboarding/StepInterests' import {StepProfile} from '#/screens/Onboarding/StepProfile' import {Portal} from '#/components/Portal' +import {ENV} from '#/env' +import {StepSuggestedAccounts} from './StepSuggestedAccounts' export function Onboarding() { const {_} = useLingui() - const [state, dispatch] = React.useReducer(reducer, { + const gate = useGate() + const showValueProp = ENV !== 'e2e' && gate('onboarding_value_prop') + const showSuggestedAccounts = + ENV !== 'e2e' && gate('onboarding_suggested_accounts') + const [state, dispatch] = useReducer(reducer, { ...initialState, + totalSteps: showSuggestedAccounts ? 4 : 3, + experiments: { + onboarding_suggested_accounts: showSuggestedAccounts, + onboarding_value_prop: showValueProp, + }, }) - const interestsDisplayNames = React.useMemo(() => { + const interestsDisplayNames = useMemo(() => { return { news: _(msg`News`), journalism: _(msg`Journalism`), @@ -45,17 +61,22 @@ export function Onboarding() { return ( <Portal> <OnboardingControls.Provider> - <Context.Provider - value={React.useMemo( - () => ({state, dispatch, interestsDisplayNames}), - [state, dispatch, interestsDisplayNames], - )}> - <Layout> - {state.activeStep === 'profile' && <StepProfile />} - {state.activeStep === 'interests' && <StepInterests />} - {state.activeStep === 'finished' && <StepFinished />} - </Layout> - </Context.Provider> + <OnboardingHeaderSlot.Provider> + <Context.Provider + value={useMemo( + () => ({state, dispatch, interestsDisplayNames}), + [state, dispatch, interestsDisplayNames], + )}> + <Layout> + {state.activeStep === 'profile' && <StepProfile />} + {state.activeStep === 'interests' && <StepInterests />} + {state.activeStep === 'suggested-accounts' && ( + <StepSuggestedAccounts /> + )} + {state.activeStep === 'finished' && <StepFinished />} + </Layout> + </Context.Provider> + </OnboardingHeaderSlot.Provider> </OnboardingControls.Provider> </Portal> ) diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts index cbb466245..31f6eb039 100644 --- a/src/screens/Onboarding/state.ts +++ b/src/screens/Onboarding/state.ts @@ -11,7 +11,7 @@ import { export type OnboardingState = { hasPrev: boolean totalSteps: number - activeStep: 'profile' | 'interests' | 'finished' + activeStep: 'profile' | 'interests' | 'suggested-accounts' | 'finished' activeStepIndex: number interestsStepResults: { @@ -34,6 +34,11 @@ export type OnboardingState = { backgroundColor: AvatarColor } } + + experiments?: { + onboarding_suggested_accounts?: boolean + onboarding_value_prop?: boolean + } } export type OnboardingAction = @@ -160,22 +165,49 @@ export function reducer( switch (a.type) { case 'next': { - if (s.activeStep === 'profile') { - next.activeStep = 'interests' - next.activeStepIndex = 2 - } else if (s.activeStep === 'interests') { - next.activeStep = 'finished' - next.activeStepIndex = 3 + if (s.experiments?.onboarding_suggested_accounts) { + if (s.activeStep === 'profile') { + next.activeStep = 'interests' + next.activeStepIndex = 2 + } else if (s.activeStep === 'interests') { + next.activeStep = 'suggested-accounts' + next.activeStepIndex = 3 + } + if (s.activeStep === 'suggested-accounts') { + next.activeStep = 'finished' + next.activeStepIndex = 4 + } + } else { + if (s.activeStep === 'profile') { + next.activeStep = 'interests' + next.activeStepIndex = 2 + } else if (s.activeStep === 'interests') { + next.activeStep = 'finished' + next.activeStepIndex = 3 + } } break } case 'prev': { - if (s.activeStep === 'interests') { - next.activeStep = 'profile' - next.activeStepIndex = 1 - } else if (s.activeStep === 'finished') { - next.activeStep = 'interests' - next.activeStepIndex = 2 + if (s.experiments?.onboarding_suggested_accounts) { + if (s.activeStep === 'interests') { + next.activeStep = 'profile' + next.activeStepIndex = 1 + } else if (s.activeStep === 'suggested-accounts') { + next.activeStep = 'interests' + next.activeStepIndex = 2 + } else if (s.activeStep === 'finished') { + next.activeStep = 'suggested-accounts' + next.activeStepIndex = 3 + } + } else { + if (s.activeStep === 'interests') { + next.activeStep = 'profile' + next.activeStepIndex = 1 + } else if (s.activeStep === 'finished') { + next.activeStep = 'interests' + next.activeStepIndex = 2 + } } break } diff --git a/src/screens/Onboarding/util.ts b/src/screens/Onboarding/util.ts index d14c9562e..b08f0408e 100644 --- a/src/screens/Onboarding/util.ts +++ b/src/screens/Onboarding/util.ts @@ -1,9 +1,9 @@ import { - $Typed, - AppBskyGraphFollow, - AppBskyGraphGetFollows, - BskyAgent, - ComAtprotoRepoApplyWrites, + type $Typed, + type AppBskyGraphFollow, + type AppBskyGraphGetFollows, + type BskyAgent, + type ComAtprotoRepoApplyWrites, } from '@atproto/api' import {TID} from '@atproto/common-web' import chunk from 'lodash.chunk' @@ -42,10 +42,10 @@ export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) { } await whenFollowsIndexed(agent, session.did, res => !!res.data.follows.length) - const followUris = new Map() + const followUris = new Map<string, string>() for (const r of followWrites) { followUris.set( - r.value.subject, + r.value.subject as string, `at://${session.did}/app.bsky.graph.follow/${r.rkey}`, ) } 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..b59397b0b 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -32,20 +32,21 @@ 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, REPLY_LINE_WIDTH, } from '#/screens/PostThread/const' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {colors} from '#/components/Admonition' import {Button} from '#/components/Button' import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {InlineLinkText, Link} from '#/components/Link' +import {LoggedOutCTA} from '#/components/LoggedOutCTA' import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' @@ -178,7 +179,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ const {_, i18n} = useLingui() const {openComposer} = useOpenComposer() const {currentAccount, hasSession} = useSession() - const feedFeedback = useFeedFeedback(postSource?.feed, hasSession) + const {gtTablet} = useBreakpoints() + const feedFeedback = useFeedFeedback(postSource?.feedSourceInfo, hasSession) const post = postShadow const record = item.value.post.record @@ -311,6 +313,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ }, isRoot && [a.pt_lg], ]}> + {/* Show CTA for logged-out visitors - hide on desktop and check gate */} + {!gtTablet && <LoggedOutCTA gateName="cta_above_post_heading" />} <View style={[a.flex_row, a.gap_md, a.pb_md]}> <View collapsable={false}> <PreviewableUserAvatar @@ -367,7 +371,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..c27f2c322 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, @@ -38,6 +38,7 @@ import { import {atoms as a, native, platform, useBreakpoints, web} from '#/alf' import * as Layout from '#/components/Layout' import {ListFooter} from '#/components/Lists' +import {LoggedOutCTA} from '#/components/LoggedOutCTA' const PARENT_CHUNK_SIZE = 5 const CHILDREN_CHUNK_SIZE = 50 @@ -48,7 +49,10 @@ export function PostThread({uri}: {uri: string}) { const initialNumToRender = useInitialNumToRender() const {height: windowHeight} = useWindowDimensions() const anchorPostSource = useUnstablePostSource(uri) - const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) + const feedFeedback = useFeedFeedback( + anchorPostSource?.feedSourceInfo, + hasSession, + ) /* * One query to rule them all @@ -405,6 +409,8 @@ export function PostThread({uri}: {uri: string}) { onPostSuccess={optimisticOnPostReply} postSource={anchorPostSource} /> + {/* Show CTA for logged-out visitors */} + <LoggedOutCTA style={a.px_lg} gateName="cta_above_post_replies" /> </View> ) } else { @@ -455,7 +461,7 @@ export function PostThread({uri}: {uri: string}) { return ( <View> {gtMobile && ( - <PostThreadComposePrompt onPressCompose={onReplyToAnchor} /> + <ThreadComposePrompt onPressCompose={onReplyToAnchor} /> )} </View> ) @@ -586,7 +592,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/Profile/Header/EditProfileDialog.tsx b/src/screens/Profile/Header/EditProfileDialog.tsx index 95160ce86..eb9e9179d 100644 --- a/src/screens/Profile/Header/EditProfileDialog.tsx +++ b/src/screens/Profile/Header/EditProfileDialog.tsx @@ -8,7 +8,6 @@ import {urls} from '#/lib/constants' import {cleanError} from '#/lib/strings/errors' import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers' import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' import {type ImageMeta} from '#/state/gallery' import {useProfileUpdateMutation} from '#/state/queries/profile' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' @@ -44,20 +43,6 @@ export function EditProfileDialog({ const cancelControl = Dialog.useDialogControl() const [dirty, setDirty] = useState(false) - // 'You might lose unsaved changes' warning - useEffect(() => { - if (isWeb && dirty) { - const abortController = new AbortController() - const {signal} = abortController - window.addEventListener('beforeunload', evt => evt.preventDefault(), { - signal, - }) - return () => { - abortController.abort() - } - } - }, [dirty]) - const onPressCancel = useCallback(() => { if (dirty) { cancelControl.open() @@ -73,6 +58,15 @@ export function EditProfileDialog({ preventDismiss: dirty, minHeight: SCREEN_HEIGHT, }} + webOptions={{ + onBackgroundPress: () => { + if (dirty) { + cancelControl.open() + } else { + control.close() + } + }, + }} testID="editProfileModal"> <DialogInner profile={profile} @@ -353,9 +347,14 @@ function DialogInner({ You are verified. You will lose your verification status if you change your display name.{' '} <InlineLinkText - label={_(msg`Learn more`)} + label={_( + msg({ + message: `Learn more`, + context: `english-only-resource`, + }), + )} to={urls.website.blog.initialVerificationAnnouncement}> - <Trans>Learn more.</Trans> + <Trans context="english-only-resource">Learn more.</Trans> </InlineLinkText> </Trans> </Admonition> diff --git a/src/screens/Profile/Header/SuggestedFollows.tsx b/src/screens/Profile/Header/SuggestedFollows.tsx index d005d888e..58a507e08 100644 --- a/src/screens/Profile/Header/SuggestedFollows.tsx +++ b/src/screens/Profile/Header/SuggestedFollows.tsx @@ -28,7 +28,6 @@ export function AnimatedProfileHeaderSuggestedFollows({ actorDid: string }) { const gate = useGate() - if (!gate('post_follow_profile_suggested_accounts')) return null /* NOTE (caidanw): * Android does not work well with this feature yet. @@ -37,6 +36,8 @@ export function AnimatedProfileHeaderSuggestedFollows({ **/ if (isAndroid) return null + if (!gate('post_follow_profile_suggested_accounts')) return null + return ( <AccordionAnimation isExpanded={isExpanded}> <ProfileHeaderSuggestedFollows actorDid={actorDid} /> diff --git a/src/screens/Profile/ProfileFeed/index.tsx b/src/screens/Profile/ProfileFeed/index.tsx index 2f4b87015..b97fc4ed5 100644 --- a/src/screens/Profile/ProfileFeed/index.tsx +++ b/src/screens/Profile/ProfileFeed/index.tsx @@ -169,7 +169,7 @@ export function ProfileFeedScreenInner({ const [hasNew, setHasNew] = React.useState(false) const [isScrolledDown, setIsScrolledDown] = React.useState(false) const queryClient = useQueryClient() - const feedFeedback = useFeedFeedback(feed, hasSession) + const feedFeedback = useFeedFeedback(feedInfo, hasSession) const scrollElRef = useAnimatedRef() as ListRef const onScrollToTop = useCallback(() => { diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx index 9c4dd9d56..f8bb947a8 100644 --- a/src/screens/Search/Explore.tsx +++ b/src/screens/Search/Explore.tsx @@ -14,6 +14,7 @@ import {cleanError} from '#/lib/strings/errors' import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {type MetricEvents} from '#/logger/metrics' +import {isNative} from '#/platform/detection' import {useLanguagePrefs} from '#/state/preferences/languages' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {RQKEY_ROOT_PAGINATED as useActorSearchPaginatedQueryKeyRoot} from '#/state/queries/actor-search' @@ -66,9 +67,9 @@ import { import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' import {StarterPack} from '#/components/icons/StarterPack' import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' +import {boostInterests} from '#/components/InterestTabs' import {Loader} from '#/components/Loader' import * as ProfileCard from '#/components/ProfileCard' -import {boostInterests} from '#/components/ProgressGuide/FollowDialog' import {SubtleHover} from '#/components/SubtleHover' import {Text} from '#/components/Typography' import * as ModuleHeader from './components/ModuleHeader' @@ -431,7 +432,7 @@ export function Explore({ i.push({ type: 'header', key: 'suggested-feeds-header', - title: _(msg`Discover Feeds`), + title: _(msg`Discover New Feeds`), icon: ListSparkle, searchButton: { label: _(msg`Search for more feeds`), @@ -916,8 +917,7 @@ export function Explore({ } case 'preview:header': { return ( - <ModuleHeader.Container - style={[a.pt_xs, t.atoms.border_contrast_low, a.border_b]}> + <ModuleHeader.Container style={[a.pt_xs]} bottomBorder={isNative}> {/* Very non-scientific way to avoid small gap on scroll */} <View style={[a.absolute, a.inset_0, t.atoms.bg, {top: -2}]} /> <ModuleHeader.FeedLink feed={item.feed}> diff --git a/src/screens/Search/modules/ExploreSuggestedAccounts.tsx b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx index fd37544f4..71bfd6547 100644 --- a/src/screens/Search/modules/ExploreSuggestedAccounts.tsx +++ b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx @@ -14,11 +14,9 @@ import { } from '#/screens/Onboarding/state' import {useTheme} from '#/alf' import {atoms as a} from '#/alf' -import {Button} from '#/components/Button' +import {boostInterests, InterestTabs} from '#/components/InterestTabs' import * as ProfileCard from '#/components/ProfileCard' -import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog' import {SubtleHover} from '#/components/SubtleHover' -import {Text} from '#/components/Typography' import type * as bsky from '#/types/bsky' export function useLoadEnoughProfiles({ @@ -59,10 +57,12 @@ export function SuggestedAccountsTabBar({ selectedInterest, onSelectInterest, hideDefaultTab, + defaultTabLabel, }: { selectedInterest: string | null onSelectInterest: (interest: string | null) => void hideDefaultTab?: boolean + defaultTabLabel?: string }) { const {_} = useLingui() const interestsDisplayNames = useInterestsDisplayNames() @@ -71,9 +71,10 @@ export function SuggestedAccountsTabBar({ const interests = Object.keys(interestsDisplayNames) .sort(boostInterests(popularInterests)) .sort(boostInterests(personalizedInterests)) + return ( <BlockDrawerGesture> - <Tabs + <InterestTabs interests={hideDefaultTab ? interests : ['all', ...interests]} selectedInterest={ selectedInterest || (hideDefaultTab ? interests[0] : 'all') @@ -86,82 +87,19 @@ export function SuggestedAccountsTabBar({ ) onSelectInterest(tab === 'all' ? null : tab) }} - hasSearchText={false} interestsDisplayNames={ hideDefaultTab ? interestsDisplayNames : { - all: _(msg`For You`), + all: defaultTabLabel || _(msg`For You`), ...interestsDisplayNames, } } - TabComponent={Tab} - contentContainerStyle={[ - { - // visual alignment - paddingLeft: a.px_md.paddingLeft, - }, - ]} /> </BlockDrawerGesture> ) } -let Tab = ({ - onSelectTab, - interest, - active, - index, - interestsDisplayName, - onLayout, -}: { - onSelectTab: (index: number) => void - interest: string - active: boolean - index: number - interestsDisplayName: string - onLayout: (index: number, x: number, width: number) => void -}): React.ReactNode => { - const t = useTheme() - const {_} = useLingui() - const activeText = active ? _(msg` (active)`) : '' - return ( - <View - key={interest} - onLayout={e => - onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) - }> - <Button - label={_(msg`Search for "${interestsDisplayName}"${activeText}`)} - onPress={() => onSelectTab(index)}> - {({hovered, pressed, focused}) => ( - <View - style={[ - a.rounded_full, - a.px_lg, - a.py_sm, - a.border, - active || hovered || pressed || focused - ? [t.atoms.bg_contrast_25, t.atoms.border_contrast_medium] - : [t.atoms.bg, t.atoms.border_contrast_low], - ]}> - <Text - style={[ - a.font_medium, - active || hovered || pressed || focused - ? t.atoms.text - : t.atoms.text_contrast_medium, - ]}> - {interestsDisplayName} - </Text> - </View> - )} - </Button> - </View> - ) -} -Tab = memo(Tab) - /** * Profile card for suggested accounts. Note: border is on the bottom edge */ diff --git a/src/screens/Search/util/useSuggestedUsers.ts b/src/screens/Search/util/useSuggestedUsers.ts index aa29dad8c..9ca2c558a 100644 --- a/src/screens/Search/util/useSuggestedUsers.ts +++ b/src/screens/Search/util/useSuggestedUsers.ts @@ -11,6 +11,7 @@ import {useInterestsDisplayNames} from '#/screens/Onboarding/state' export function useSuggestedUsers({ category = null, search = false, + overrideInterests, }: { category?: string | null /** @@ -18,11 +19,17 @@ export function useSuggestedUsers({ * based on the user's "app language setting */ search?: boolean + /** + * In onboarding, interests haven't been saved to prefs yet, so we need to + * pass them down through here + */ + overrideInterests?: string[] }) { const interestsDisplayNames = useInterestsDisplayNames() const curated = useGetSuggestedUsersQuery({ enabled: !search, category, + overrideInterests, }) const searched = useActorSearchPaginated({ enabled: !!search, @@ -43,6 +50,7 @@ export function useSuggestedUsers({ isLoading: searched.isLoading, error: searched.error, isRefetching: searched.isRefetching, + refetch: searched.refetch, } } else { return { @@ -50,6 +58,7 @@ export function useSuggestedUsers({ isLoading: curated.isLoading, error: curated.error, isRefetching: curated.isRefetching, + refetch: curated.refetch, } } }, [curated, searched, search]) diff --git a/src/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx index 86652d277..8f320459c 100644 --- a/src/screens/Settings/AccountSettings.tsx +++ b/src/screens/Settings/AccountSettings.tsx @@ -25,6 +25,7 @@ import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/ic import {Trash_Stroke2_Corner2_Rounded} from '#/components/icons/Trash' import * as Layout from '#/components/Layout' import {ChangeHandleDialog} from './components/ChangeHandleDialog' +import {ChangePasswordDialog} from './components/ChangePasswordDialog' import {DeactivateAccountDialog} from './components/DeactivateAccountDialog' import {ExportCarDialog} from './components/ExportCarDialog' @@ -37,6 +38,7 @@ export function AccountSettingsScreen({}: Props) { const emailDialogControl = useEmailDialogControl() const birthdayControl = useDialogControl() const changeHandleControl = useDialogControl() + const changePasswordControl = useDialogControl() const exportCarControl = useDialogControl() const deactivateAccountControl = useDialogControl() @@ -117,7 +119,7 @@ export function AccountSettingsScreen({}: Props) { <SettingsList.Divider /> <SettingsList.PressableItem label={_(msg`Password`)} - onPress={() => openModal({name: 'change-password'})}> + onPress={() => changePasswordControl.open()}> <SettingsList.ItemIcon icon={LockIcon} /> <SettingsList.ItemText> <Trans>Password</Trans> @@ -180,6 +182,7 @@ export function AccountSettingsScreen({}: Props) { <BirthDateSettingsDialog control={birthdayControl} /> <ChangeHandleDialog control={changeHandleControl} /> + <ChangePasswordDialog control={changePasswordControl} /> <ExportCarDialog control={exportCarControl} /> <DeactivateAccountDialog control={deactivateAccountControl} /> </Layout.Screen> 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/Settings/components/ChangeHandleDialog.tsx b/src/screens/Settings/components/ChangeHandleDialog.tsx index 59e004252..8002c172f 100644 --- a/src/screens/Settings/components/ChangeHandleDialog.tsx +++ b/src/screens/Settings/components/ChangeHandleDialog.tsx @@ -209,9 +209,14 @@ function ProvidedHandlePage({ You are verified. You will lose your verification status if you change your handle.{' '} <InlineLinkText - label={_(msg`Learn more`)} + label={_( + msg({ + message: `Learn more`, + context: `english-only-resource`, + }), + )} to={urls.website.blog.initialVerificationAnnouncement}> - <Trans>Learn more.</Trans> + <Trans context="english-only-resource">Learn more.</Trans> </InlineLinkText> </Trans> </Admonition> @@ -268,7 +273,12 @@ function ProvidedHandlePage({ If you have your own domain, you can use that as your handle. This lets you self-verify your identity.{' '} <InlineLinkText - label={_(msg`learn more`)} + label={_( + msg({ + message: `Learn more`, + context: `english-only-resource`, + }), + )} to="https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial" style={[a.font_bold]} disableMismatchWarning> diff --git a/src/screens/Settings/components/ChangePasswordDialog.tsx b/src/screens/Settings/components/ChangePasswordDialog.tsx new file mode 100644 index 000000000..7e3e62eee --- /dev/null +++ b/src/screens/Settings/components/ChangePasswordDialog.tsx @@ -0,0 +1,300 @@ +import {useState} from 'react' +import {useWindowDimensions, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import * as EmailValidator from 'email-validator' + +import {cleanError, isNetworkError} from '#/lib/strings/errors' +import {checkAndFormatResetCode} from '#/lib/strings/password' +import {logger} from '#/logger' +import {isNative} from '#/platform/detection' +import {useAgent, useSession} from '#/state/session' +import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' +import {android, atoms as a, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +enum Stages { + RequestCode = 'RequestCode', + ChangePassword = 'ChangePassword', + Done = 'Done', +} + +export function ChangePasswordDialog({ + control, +}: { + control: Dialog.DialogControlProps +}) { + const {height} = useWindowDimensions() + + return ( + <Dialog.Outer + control={control} + nativeOptions={android({minHeight: height / 2})}> + <Dialog.Handle /> + <Inner /> + </Dialog.Outer> + ) +} + +function Inner() { + const {_} = useLingui() + const {currentAccount} = useSession() + const agent = useAgent() + const control = Dialog.useDialogContext() + + const [stage, setStage] = useState(Stages.RequestCode) + const [isProcessing, setIsProcessing] = useState(false) + const [resetCode, setResetCode] = useState('') + const [newPassword, setNewPassword] = useState('') + const [error, setError] = useState('') + + const uiStrings = { + RequestCode: { + title: _(msg`Change your password`), + message: _( + msg`If you want to change your password, we will send you a code to verify that this is your account.`, + ), + }, + ChangePassword: { + title: _(msg`Enter code`), + message: _( + msg`Please enter the code you received and the new password you would like to use.`, + ), + }, + Done: { + title: _(msg`Password changed`), + message: _( + msg`Your password has been changed successfully! Please use your new password when you sign in to Bluesky from now on.`, + ), + }, + } + + const onRequestCode = async () => { + if ( + !currentAccount?.email || + !EmailValidator.validate(currentAccount.email) + ) { + return setError(_(msg`Your email appears to be invalid.`)) + } + + setError('') + setIsProcessing(true) + try { + await agent.com.atproto.server.requestPasswordReset({ + email: currentAccount.email, + }) + setStage(Stages.ChangePassword) + } catch (e: any) { + if (isNetworkError(e)) { + setError( + _( + msg`Unable to contact your service. Please check your internet connection and try again.`, + ), + ) + } else { + logger.error('Failed to request password reset', {safeMessage: e}) + setError(cleanError(e)) + } + } finally { + setIsProcessing(false) + } + } + + const onChangePassword = async () => { + const formattedCode = checkAndFormatResetCode(resetCode) + if (!formattedCode) { + setError( + _( + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, + ), + ) + return + } + if (!newPassword) { + setError( + _(msg`Please enter a password. It must be at least 8 characters long.`), + ) + return + } + if (newPassword.length < 8) { + setError(_(msg`Password must be at least 8 characters long.`)) + return + } + + setError('') + setIsProcessing(true) + try { + await agent.com.atproto.server.resetPassword({ + token: formattedCode, + password: newPassword, + }) + setStage(Stages.Done) + } catch (e: any) { + if (isNetworkError(e)) { + setError( + _( + msg`Unable to contact your service. Please check your internet connection and try again.`, + ), + ) + } else if (e?.toString().includes('Token is invalid')) { + setError(_(msg`This confirmation code is not valid. Please try again.`)) + } else { + logger.error('Failed to set new password', {safeMessage: e}) + setError(cleanError(e)) + } + } finally { + setIsProcessing(false) + } + } + + const onBlur = () => { + const formattedCode = checkAndFormatResetCode(resetCode) + if (!formattedCode) { + return + } + setResetCode(formattedCode) + } + + return ( + <Dialog.ScrollableInner + label={_(msg`Change password dialog`)} + style={web({maxWidth: 400})}> + <View style={[a.gap_xl]}> + <View style={[a.gap_sm]}> + <Text style={[a.font_heavy, a.text_2xl]}> + {uiStrings[stage].title} + </Text> + {error ? ( + <View style={[a.rounded_sm, a.overflow_hidden]}> + <ErrorMessage message={error} /> + </View> + ) : null} + + <Text style={[a.text_md, a.leading_snug]}> + {uiStrings[stage].message} + </Text> + </View> + + {stage === Stages.ChangePassword && ( + <View style={[a.gap_md]}> + <View> + <TextField.LabelText> + <Trans>Confirmation code</Trans> + </TextField.LabelText> + <TextField.Root> + <TextField.Input + label={_(msg`Confirmation code`)} + placeholder="XXXXX-XXXXX" + value={resetCode} + onChangeText={setResetCode} + onBlur={onBlur} + autoCapitalize="none" + autoCorrect={false} + autoComplete="one-time-code" + /> + </TextField.Root> + </View> + <View> + <TextField.LabelText> + <Trans>New password</Trans> + </TextField.LabelText> + <TextField.Root> + <TextField.Input + label={_(msg`New password`)} + placeholder={_(msg`At least 8 characters`)} + value={newPassword} + onChangeText={setNewPassword} + secureTextEntry + autoCapitalize="none" + autoComplete="new-password" + /> + </TextField.Root> + </View> + </View> + )} + + <View style={[a.gap_sm]}> + {stage === Stages.RequestCode ? ( + <> + <Button + label={_(msg`Request code`)} + color="primary" + size="large" + disabled={isProcessing} + onPress={onRequestCode}> + <ButtonText> + <Trans>Request code</Trans> + </ButtonText> + {isProcessing && <ButtonIcon icon={Loader} />} + </Button> + <Button + label={_(msg`Already have a code?`)} + onPress={() => setStage(Stages.ChangePassword)} + size="large" + color="primary_subtle" + disabled={isProcessing}> + <ButtonText> + <Trans>Already have a code?</Trans> + </ButtonText> + </Button> + {isNative && ( + <Button + label={_(msg`Cancel`)} + color="secondary" + size="large" + disabled={isProcessing} + onPress={() => control.close()}> + <ButtonText> + <Trans>Cancel</Trans> + </ButtonText> + </Button> + )} + </> + ) : stage === Stages.ChangePassword ? ( + <> + <Button + label={_(msg`Change password`)} + color="primary" + size="large" + disabled={isProcessing} + onPress={onChangePassword}> + <ButtonText> + <Trans>Change password</Trans> + </ButtonText> + {isProcessing && <ButtonIcon icon={Loader} />} + </Button> + <Button + label={_(msg`Back`)} + color="secondary" + size="large" + disabled={isProcessing} + onPress={() => { + setResetCode('') + setStage(Stages.RequestCode) + }}> + <ButtonText> + <Trans>Back</Trans> + </ButtonText> + </Button> + </> + ) : stage === Stages.Done ? ( + <Button + label={_(msg`Close`)} + color="primary" + size="large" + onPress={() => control.close()}> + <ButtonText> + <Trans>Close</Trans> + </ButtonText> + </Button> + ) : null} + </View> + </View> + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} diff --git a/src/screens/Signup/StepHandle/index.tsx b/src/screens/Signup/StepHandle/index.tsx index 5bf6b2269..64333933c 100644 --- a/src/screens/Signup/StepHandle/index.tsx +++ b/src/screens/Signup/StepHandle/index.tsx @@ -168,7 +168,10 @@ export function StepHandle() { </TextField.GhostText> )} {isHandleAvailable?.available && ( - <CheckIcon style={[{color: t.palette.positive_600}, a.z_20]} /> + <CheckIcon + testID="handleAvailableCheck" + style={[{color: t.palette.positive_600}, a.z_20]} + /> )} </TextField.Root> </View> diff --git a/src/screens/StarterPack/Wizard/State.tsx b/src/screens/StarterPack/Wizard/State.tsx index 7fae8ca6d..f34218219 100644 --- a/src/screens/StarterPack/Wizard/State.tsx +++ b/src/screens/StarterPack/Wizard/State.tsx @@ -7,7 +7,6 @@ import { import {msg, plural} from '@lingui/macro' import {STARTER_PACK_MAX_SIZE} from '#/lib/constants' -import {useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import * as bsky from '#/types/bsky' @@ -37,6 +36,7 @@ interface State { processing: boolean error?: string transitionDirection: 'Backward' | 'Forward' + targetDid?: string } type TStateContext = [State, (action: Action) => void] @@ -118,15 +118,17 @@ function reducer(state: State, action: Action): State { export function Provider({ starterPack, listItems, + targetProfile, children, }: { starterPack?: AppBskyGraphDefs.StarterPackView listItems?: AppBskyGraphDefs.ListItemView[] + targetProfile: bsky.profile.AnyProfileView children: React.ReactNode }) { - const {currentAccount} = useSession() - const createInitialState = (): State => { + const targetDid = targetProfile?.did + if ( starterPack && bsky.validate(starterPack.record, AppBskyGraphStarterpack.validateRecord) @@ -136,23 +138,22 @@ export function Provider({ currentStep: 'Details', name: starterPack.record.name, description: starterPack.record.description, - profiles: - listItems - ?.map(i => i.subject) - .filter(p => p.did !== currentAccount?.did) ?? [], + profiles: listItems?.map(i => i.subject) ?? [], feeds: starterPack.feeds ?? [], processing: false, transitionDirection: 'Forward', + targetDid, } } return { canNext: true, currentStep: 'Details', - profiles: [], + profiles: [targetProfile], feeds: [], processing: false, transitionDirection: 'Forward', + targetDid, } } diff --git a/src/screens/StarterPack/Wizard/index.tsx b/src/screens/StarterPack/Wizard/index.tsx index 8256349df..839faf9aa 100644 --- a/src/screens/StarterPack/Wizard/index.tsx +++ b/src/screens/StarterPack/Wizard/index.tsx @@ -68,12 +68,19 @@ export function Wizard({ CommonNavigatorParams, 'StarterPackEdit' | 'StarterPackWizard' >) { - const {rkey} = route.params ?? {} + const params = route.params ?? {} + const rkey = 'rkey' in params ? params.rkey : undefined + const fromDialog = 'fromDialog' in params ? params.fromDialog : false + const targetDid = 'targetDid' in params ? params.targetDid : undefined + const onSuccess = 'onSuccess' in params ? params.onSuccess : undefined const {currentAccount} = useSession() const moderationOpts = useModerationOpts() const {_} = useLingui() + // Use targetDid if provided (from dialog), otherwise use current account + const profileDid = targetDid || currentAccount!.did + const { data: starterPack, isLoading: isLoadingStarterPack, @@ -91,7 +98,7 @@ export function Wizard({ data: profile, isLoading: isLoadingProfile, isError: isErrorProfile, - } = useProfileQuery({did: currentAccount?.did}) + } = useProfileQuery({did: profileDid}) const isEdit = Boolean(rkey) const isReady = @@ -127,12 +134,17 @@ export function Wizard({ <Layout.Screen testID="starterPackWizardScreen" style={web([{minHeight: 0}, a.flex_1])}> - <Provider starterPack={starterPack} listItems={listItems}> + <Provider + starterPack={starterPack} + listItems={listItems} + targetProfile={profile}> <WizardInner currentStarterPack={starterPack} currentListItems={listItems} profile={profile} moderationOpts={moderationOpts} + fromDialog={fromDialog} + onSuccess={onSuccess} /> </Provider> </Layout.Screen> @@ -144,17 +156,22 @@ function WizardInner({ currentListItems, profile, moderationOpts, + fromDialog, + onSuccess, }: { currentStarterPack?: AppBskyGraphDefs.StarterPackView currentListItems?: AppBskyGraphDefs.ListItemView[] profile: AppBskyActorDefs.ProfileViewDetailed moderationOpts: ModerationOpts + fromDialog?: boolean + onSuccess?: () => void }) { const navigation = useNavigation<NavigationProp>() const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() const [state, dispatch] = useWizardState() const {currentAccount} = useSession() + const {data: currentProfile} = useProfileQuery({ did: currentAccount?.did, staleTime: 0, @@ -213,11 +230,17 @@ function WizardInner({ }) Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)]) dispatch({type: 'SetProcessing', processing: false}) - navigation.replace('StarterPack', { - name: currentAccount!.handle, - rkey, - new: true, - }) + + if (fromDialog) { + navigation.goBack() + onSuccess?.() + } else { + navigation.replace('StarterPack', { + name: profile!.handle, + rkey, + new: true, + }) + } } const onSuccessEdit = () => { @@ -285,10 +308,7 @@ function WizardInner({ ) } - const items = - state.currentStep === 'Profiles' - ? [profile, ...state.profiles] - : state.feeds + const items = state.currentStep === 'Profiles' ? state.profiles : state.feeds const isEditEnabled = (state.currentStep === 'Profiles' && items.length > 1) || @@ -340,11 +360,7 @@ function WizardInner({ </Container> {state.currentStep !== 'Details' && ( - <Footer - onNext={onNext} - nextBtnText={currUiStrings.nextBtn} - profile={profile} - /> + <Footer onNext={onNext} nextBtnText={currUiStrings.nextBtn} /> )} <WizardEditListDialog control={editDialogControl} @@ -392,20 +408,15 @@ function Container({children}: {children: React.ReactNode}) { function Footer({ onNext, nextBtnText, - profile, }: { onNext: () => void nextBtnText: string - profile: AppBskyActorDefs.ProfileViewDetailed }) { const t = useTheme() const [state] = useWizardState() const {bottom: bottomInset} = useSafeAreaInsets() - - const items = - state.currentStep === 'Profiles' - ? [profile, ...state.profiles] - : state.feeds + const {currentAccount} = useSession() + const items = state.currentStep === 'Profiles' ? state.profiles : state.feeds const minimumItems = state.currentStep === 'Profiles' ? 8 : 0 @@ -471,19 +482,44 @@ function Footer({ <Text style={[a.text_center, textStyles]}> { items.length < 2 ? ( - <Trans> - It's just you right now! Add more people to your starter pack - by searching above. - </Trans> + currentAccount?.did === items[0].did ? ( + <Trans> + It's just you right now! Add more people to your starter + pack by searching above. + </Trans> + ) : ( + <Trans> + It's just{' '} + <Text style={[a.font_bold, textStyles]} emoji> + {getName(items[0])}{' '} + </Text> + right now! Add more people to your starter pack by searching + above. + </Trans> + ) ) : items.length === 2 ? ( - <Trans> - <Text style={[a.font_bold, textStyles]}>You</Text> and - <Text> </Text> - <Text style={[a.font_bold, textStyles]} emoji> - {getName(items[1] /* [0] is self, skip it */)}{' '} - </Text> - are included in your starter pack - </Trans> + currentAccount?.did === items[0].did ? ( + <Trans> + <Text style={[a.font_bold, textStyles]}>You</Text> and + <Text> </Text> + <Text style={[a.font_bold, textStyles]} emoji> + {getName(items[1] /* [0] is self, skip it */)}{' '} + </Text> + are included in your starter pack + </Trans> + ) : ( + <Trans> + <Text style={[a.font_bold, textStyles]}> + {getName(items[0])} + </Text>{' '} + and + <Text> </Text> + <Text style={[a.font_bold, textStyles]} emoji> + {getName(items[1] /* [0] is self, skip it */)}{' '} + </Text> + are included in your starter pack + </Trans> + ) ) : items.length > 2 ? ( <Trans context="profiles"> <Text style={[a.font_bold, textStyles]} emoji> diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx index b53593010..1d7c2dd53 100644 --- a/src/screens/VideoFeed/index.tsx +++ b/src/screens/VideoFeed/index.tsx @@ -70,6 +70,7 @@ import { useFeedFeedbackContext, } from '#/state/feed-feedback' import {useFeedFeedback} from '#/state/feed-feedback' +import {useFeedInfo} from '#/state/queries/feed' import {usePostLikeMutationQueue} from '#/state/queries/post' import { type AuthorFilter, @@ -80,9 +81,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' @@ -199,7 +200,9 @@ function Feed() { throw new Error(`Invalid video feed params ${JSON.stringify(params)}`) } }, [params]) - const feedFeedback = useFeedFeedback(feedDesc, hasSession) + const feedUri = params.type === 'feedgen' ? params.uri : undefined + const {data: feedInfo} = useFeedInfo(feedUri) + const feedFeedback = useFeedFeedback(feedInfo, hasSession) const {data, error, hasNextPage, isFetchingNextPage, fetchNextPage} = usePostFeedQuery( feedDesc, @@ -883,7 +886,7 @@ function Overlay({ player={player} seekingAnimationSV={seekingAnimationSV} scrollGesture={scrollGesture}> - <PostThreadComposePrompt + <ThreadComposePrompt onPressCompose={onPressReply} style={[a.pt_md, a.pb_sm]} /> |