Merge remote-tracking branch 'origin/main' into hailey/onboarding-profile

* origin/main: (222 commits)
  Release 1.77.0 (#3549)
  Improve the language behaviors around the PWI (#3545)
  fix: only close drawer if directly tapping backdrop (#3534)
  Fix Android in-app browser closing when switching apps (#3546)
  Cache DID and profile basic on profile card presses (#3523)
  Remove extra wrapper on notification user links (#3548)
  Fix hover card animation with a reducer (#3547)
  Update Ukrainian translation (#3537)
  add oEmbed links to public post HTML meta headers (#3522)
  rebased embedr (#3511)
  Increase hit slop for web's app language picker (#3535)
  Update Japanese translation (#3425)
  Fix oopsie (#3538)
  Fade in animation for card (#3521)
  [Embeds] Add ref_url (#3533)
  add ref_src (#3532)
  [Embed] Don't reuse DOM when changing embed (#3530)
  [Statsig] Send ref source (#3531)
  More obvious click area (#3528)
  add hideRecord prop (#3527)
  ...
This commit is contained in:
Eric Bailey
2024-04-14 10:29:43 -05:00
420 changed files with 70667 additions and 28907 deletions
+2 -2
View File
@@ -147,7 +147,7 @@ export function Deactivated() {
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={logout}>
onPress={() => logout('Deactivated')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
@@ -176,7 +176,7 @@ export function Deactivated() {
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={logout}>
onPress={() => logout('Deactivated')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
+33 -33
View File
@@ -1,28 +1,30 @@
import React from 'react'
import {ListRenderItemInfo, Pressable} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {useSetMinimalShellMode} from 'state/shell'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from 'lib/routes/types'
import {useSearchPostsQuery} from 'state/queries/search-posts'
import {Post} from 'view/com/post/Post'
import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {HITSLOP_10} from 'lib/constants'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {CommonNavigatorParams} from 'lib/routes/types'
import {shareUrl} from 'lib/sharing'
import {cleanError} from 'lib/strings/errors'
import {sanitizeHandle} from 'lib/strings/handles'
import {enforceLen} from 'lib/strings/helpers'
import {isNative} from 'platform/detection'
import {useSearchPostsQuery} from 'state/queries/search-posts'
import {useSetMinimalShellMode} from 'state/shell'
import {Post} from 'view/com/post/Post'
import {List} from 'view/com/util/List'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded} from '#/components/icons/ArrowOutOfBox'
import {
ListFooter,
ListHeaderDesktop,
ListMaybePlaceholder,
} from '#/components/Lists'
import {List} from 'view/com/util/List'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {sanitizeHandle} from 'lib/strings/handles'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded} from '#/components/icons/ArrowOutOfBox'
import {shareUrl} from 'lib/sharing'
import {HITSLOP_10} from 'lib/constants'
import {isNative} from 'platform/detection'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
const renderItem = ({item}: ListRenderItemInfo<PostView>) => {
return <Post post={item} />
@@ -61,9 +63,8 @@ export default function HashtagScreen({
const {
data,
isFetching,
isFetchingNextPage,
isLoading,
isRefetching,
isError,
error,
refetch,
@@ -97,9 +98,9 @@ export default function HashtagScreen({
}, [refetch])
const onEndReached = React.useCallback(() => {
if (isFetching || !hasNextPage || error) return
if (isFetchingNextPage || !hasNextPage || error) return
fetchNextPage()
}, [isFetching, hasNextPage, error, fetchNextPage])
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
return (
<>
@@ -123,16 +124,16 @@ export default function HashtagScreen({
: undefined
}
/>
<ListMaybePlaceholder
isLoading={isLoading || isRefetching}
isError={isError}
isEmpty={posts.length < 1}
onRetry={refetch}
emptyTitle="results"
emptyMessage={_(msg`We couldn't find any results for that hashtag.`)}
/>
{!isLoading && posts.length > 0 && (
<List<PostView>
{posts.length < 1 ? (
<ListMaybePlaceholder
isLoading={isLoading}
isError={isError}
onRetry={refetch}
emptyType="results"
emptyMessage={_(msg`We couldn't find any results for that hashtag.`)}
/>
) : (
<List
data={posts}
renderItem={renderItem}
keyExtractor={keyExtractor}
@@ -150,9 +151,8 @@ export default function HashtagScreen({
}
ListFooterComponent={
<ListFooter
isFetching={isFetching && !isRefetching}
isError={isError}
error={error?.name}
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
/>
}
+84
View File
@@ -0,0 +1,84 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {SessionAccount, useSession, useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a} from '#/alf'
import {AccountList} from '#/components/AccountList'
import {Button, ButtonText} from '#/components/Button'
import * as TextField from '#/components/forms/TextField'
import {FormContainer} from './FormContainer'
export const ChooseAccountForm = ({
onSelectAccount,
onPressBack,
}: {
onSelectAccount: (account?: SessionAccount) => void
onPressBack: () => void
}) => {
const {track, screen} = useAnalytics()
const {_} = useLingui()
const {currentAccount} = useSession()
const {initSession} = useSessionApi()
const {setShowLoggedOut} = useLoggedOutViewControls()
React.useEffect(() => {
screen('Choose Account')
}, [screen])
const onSelect = React.useCallback(
async (account: SessionAccount) => {
if (account.accessJwt) {
if (account.did === currentAccount?.did) {
setShowLoggedOut(false)
Toast.show(_(msg`Already signed in as @${account.handle}`))
} else {
await initSession(account)
logEvent('account:loggedIn', {
logContext: 'ChooseAccountForm',
withPassword: false,
})
track('Sign In', {resumedSession: true})
setTimeout(() => {
Toast.show(_(msg`Signed in as @${account.handle}`))
}, 100)
}
} else {
onSelectAccount(account)
}
},
[currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _],
)
return (
<FormContainer
testID="chooseAccountForm"
titleText={<Trans>Select account</Trans>}>
<View>
<TextField.LabelText>
<Trans>Sign in as...</Trans>
</TextField.LabelText>
<AccountList
onSelectAccount={onSelect}
onSelectOther={() => onSelectAccount()}
/>
</View>
<View style={[a.flex_row]}>
<Button
label={_(msg`Back`)}
variant="solid"
color="secondary"
size="medium"
onPress={onPressBack}>
<ButtonText>{_(msg`Back`)}</ButtonText>
</Button>
<View style={[a.flex_1]} />
</View>
</FormContainer>
)
}
+184
View File
@@ -0,0 +1,184 @@
import React, {useEffect, useState} from 'react'
import {ActivityIndicator, Keyboard, View} from 'react-native'
import {ComAtprotoServerDescribeServer} from '@atproto/api'
import {BskyAgent} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as EmailValidator from 'email-validator'
import {useAnalytics} from '#/lib/analytics/analytics'
import {isNetworkError} from '#/lib/strings/errors'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError'
import {HostingProvider} from '#/components/forms/HostingProvider'
import * as TextField from '#/components/forms/TextField'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {Text} from '#/components/Typography'
import {FormContainer} from './FormContainer'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
export const ForgotPasswordForm = ({
error,
serviceUrl,
serviceDescription,
setError,
setServiceUrl,
onPressBack,
onEmailSent,
}: {
error: string
serviceUrl: string
serviceDescription: ServiceDescription | undefined
setError: (v: string) => void
setServiceUrl: (v: string) => void
onPressBack: () => void
onEmailSent: () => void
}) => {
const t = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [email, setEmail] = useState<string>('')
const {screen} = useAnalytics()
const {_} = useLingui()
useEffect(() => {
screen('Signin:ForgotPassword')
}, [screen])
const onPressSelectService = React.useCallback(() => {
Keyboard.dismiss()
}, [])
const onPressNext = async () => {
if (!EmailValidator.validate(email)) {
return setError(_(msg`Your email appears to be invalid.`))
}
setError('')
setIsProcessing(true)
try {
const agent = new BskyAgent({service: serviceUrl})
await agent.com.atproto.server.requestPasswordReset({email})
onEmailSent()
} catch (e: any) {
const errMsg = e.toString()
logger.warn('Failed to request password reset', {error: e})
setIsProcessing(false)
if (isNetworkError(e)) {
setError(
_(
msg`Unable to contact your service. Please check your Internet connection.`,
),
)
} else {
setError(cleanError(errMsg))
}
}
}
return (
<FormContainer
testID="forgotPasswordForm"
titleText={<Trans>Reset password</Trans>}>
<View>
<TextField.LabelText>
<Trans>Hosting provider</Trans>
</TextField.LabelText>
<HostingProvider
serviceUrl={serviceUrl}
onSelectServiceUrl={setServiceUrl}
onOpenDialog={onPressSelectService}
/>
</View>
<View>
<TextField.LabelText>
<Trans>Email address</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={At} />
<TextField.Input
testID="forgotPasswordEmail"
label={_(msg`Enter your email address`)}
autoCapitalize="none"
autoFocus
autoCorrect={false}
autoComplete="email"
value={email}
onChangeText={setEmail}
editable={!isProcessing}
accessibilityHint={_(msg`Sets email for password reset`)}
/>
</TextField.Root>
</View>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
<Trans>
Enter the email you used to create your account. We'll send you a
"reset code" so you can set a new password.
</Trans>
</Text>
<FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_md]}>
<Button
label={_(msg`Back`)}
variant="solid"
color="secondary"
size="medium"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
{!serviceDescription || isProcessing ? (
<ActivityIndicator />
) : (
<Button
label={_(msg`Next`)}
variant="solid"
color={'primary'}
size="medium"
onPress={onPressNext}
disabled={!email}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
</Button>
)}
{!serviceDescription || isProcessing ? (
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
<Trans>Processing...</Trans>
</Text>
) : undefined}
</View>
<View
style={[
t.atoms.border_contrast_medium,
a.border_t,
a.pt_2xl,
a.mt_md,
a.flex_row,
a.justify_center,
]}>
<Button
testID="skipSendEmailButton"
onPress={onEmailSent}
label={_(msg`Go to next`)}
accessibilityHint={_(msg`Navigates to the next screen`)}
size="medium"
variant="ghost"
color="secondary">
<ButtonText>
<Trans>Already have a code?</Trans>
</ButtonText>
</Button>
</View>
</FormContainer>
)
}
+32
View File
@@ -0,0 +1,32 @@
import React from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
export function FormContainer({
testID,
titleText,
children,
style,
}: {
testID?: string
titleText?: React.ReactNode
children: React.ReactNode
style?: StyleProp<ViewStyle>
}) {
const {gtMobile} = useBreakpoints()
const t = useTheme()
return (
<View
testID={testID}
style={[a.gap_md, a.flex_1, !gtMobile && [a.px_lg, a.py_md], style]}>
{titleText && !gtMobile && (
<Text style={[a.text_xl, a.font_bold, t.atoms.text_contrast_high]}>
{titleText}
</Text>
)}
{children}
</View>
)
}
+269
View File
@@ -0,0 +1,269 @@
import React, {useRef, useState} from 'react'
import {
ActivityIndicator,
Keyboard,
LayoutAnimation,
TextInput,
View,
} from 'react-native'
import {ComAtprotoServerDescribeServer} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {isNetworkError} from '#/lib/strings/errors'
import {cleanError} from '#/lib/strings/errors'
import {createFullHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useSessionApi} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError'
import {HostingProvider} from '#/components/forms/HostingProvider'
import * as TextField from '#/components/forms/TextField'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {FormContainer} from './FormContainer'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
export const LoginForm = ({
error,
serviceUrl,
serviceDescription,
initialHandle,
setError,
setServiceUrl,
onPressRetryConnect,
onPressBack,
onPressForgotPassword,
}: {
error: string
serviceUrl: string
serviceDescription: ServiceDescription | undefined
initialHandle: string
setError: (v: string) => void
setServiceUrl: (v: string) => void
onPressRetryConnect: () => void
onPressBack: () => void
onPressForgotPassword: () => void
}) => {
const {track} = useAnalytics()
const t = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [identifier, setIdentifier] = useState<string>(initialHandle)
const [password, setPassword] = useState<string>('')
const passwordInputRef = useRef<TextInput>(null)
const {_} = useLingui()
const {login} = useSessionApi()
const onPressSelectService = React.useCallback(() => {
Keyboard.dismiss()
track('Signin:PressedSelectService')
}, [track])
const onPressNext = async () => {
if (isProcessing) return
Keyboard.dismiss()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setError('')
setIsProcessing(true)
try {
// try to guess the handle if the user just gave their own username
let fullIdent = identifier
if (
!identifier.includes('@') && // not an email
!identifier.includes('.') && // not a domain
serviceDescription &&
serviceDescription.availableUserDomains.length > 0
) {
let matched = false
for (const domain of serviceDescription.availableUserDomains) {
if (fullIdent.endsWith(domain)) {
matched = true
}
}
if (!matched) {
fullIdent = createFullHandle(
identifier,
serviceDescription.availableUserDomains[0],
)
}
}
// TODO remove double login
await login(
{
service: serviceUrl,
identifier: fullIdent,
password,
},
'LoginForm',
)
} catch (e: any) {
const errMsg = e.toString()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setIsProcessing(false)
if (errMsg.includes('Authentication Required')) {
logger.debug('Failed to login due to invalid credentials', {
error: errMsg,
})
setError(_(msg`Invalid username or password`))
} else if (isNetworkError(e)) {
logger.warn('Failed to login due to network error', {error: errMsg})
setError(
_(
msg`Unable to contact your service. Please check your Internet connection.`,
),
)
} else {
logger.warn('Failed to login', {error: errMsg})
setError(cleanError(errMsg))
}
}
}
const isReady = !!serviceDescription && !!identifier && !!password
return (
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
<View>
<TextField.LabelText>
<Trans>Hosting provider</Trans>
</TextField.LabelText>
<HostingProvider
serviceUrl={serviceUrl}
onSelectServiceUrl={setServiceUrl}
onOpenDialog={onPressSelectService}
/>
</View>
<View>
<TextField.LabelText>
<Trans>Account</Trans>
</TextField.LabelText>
<View style={[a.gap_sm]}>
<TextField.Root>
<TextField.Icon icon={At} />
<TextField.Input
testID="loginUsernameInput"
label={_(msg`Username or email address`)}
autoCapitalize="none"
autoFocus
autoCorrect={false}
autoComplete="username"
returnKeyType="next"
textContentType="username"
onSubmitEditing={() => {
passwordInputRef.current?.focus()
}}
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
value={identifier}
onChangeText={str =>
setIdentifier((str || '').toLowerCase().trim())
}
editable={!isProcessing}
accessibilityHint={_(
msg`Input the username or email address you used at signup`,
)}
/>
</TextField.Root>
<TextField.Root>
<TextField.Icon icon={Lock} />
<TextField.Input
testID="loginPasswordInput"
inputRef={passwordInputRef}
label={_(msg`Password`)}
autoCapitalize="none"
autoCorrect={false}
autoComplete="password"
returnKeyType="done"
enablesReturnKeyAutomatically={true}
secureTextEntry={true}
textContentType="password"
clearButtonMode="while-editing"
value={password}
onChangeText={setPassword}
onSubmitEditing={onPressNext}
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
editable={!isProcessing}
accessibilityHint={
identifier === ''
? _(msg`Input your password`)
: _(msg`Input the password tied to ${identifier}`)
}
/>
<Button
testID="forgotPasswordButton"
onPress={onPressForgotPassword}
label={_(msg`Forgot password?`)}
accessibilityHint={_(msg`Opens password reset form`)}
variant="solid"
color="secondary"
style={[
a.rounded_sm,
// t.atoms.bg_contrast_100,
{marginLeft: 'auto', left: 6, padding: 6},
a.z_10,
]}>
<ButtonText>
<Trans>Forgot?</Trans>
</ButtonText>
</Button>
</TextField.Root>
</View>
</View>
<FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_md]}>
<Button
label={_(msg`Back`)}
variant="solid"
color="secondary"
size="medium"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
{!serviceDescription && error ? (
<Button
testID="loginRetryButton"
label={_(msg`Retry`)}
accessibilityHint={_(msg`Retries login`)}
variant="solid"
color="secondary"
size="medium"
onPress={onPressRetryConnect}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
</Button>
) : !serviceDescription ? (
<>
<ActivityIndicator />
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
<Trans>Connecting...</Trans>
</Text>
</>
) : isReady ? (
<Button
testID="loginNextButton"
label={_(msg`Next`)}
accessibilityHint={_(msg`Navigates to the next screen`)}
variant="solid"
color="primary"
size="medium"
onPress={onPressNext}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
) : undefined}
</View>
</FormContainer>
)
}
+50
View File
@@ -0,0 +1,50 @@
import React, {useEffect} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
import {FormContainer} from './FormContainer'
export const PasswordUpdatedForm = ({
onPressNext,
}: {
onPressNext: () => void
}) => {
const {screen} = useAnalytics()
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
useEffect(() => {
screen('Signin:PasswordUpdatedForm')
}, [screen])
return (
<FormContainer
testID="passwordUpdatedForm"
style={[a.gap_2xl, !gtMobile && a.mt_5xl]}>
<Text style={[a.text_3xl, a.font_bold, a.text_center]}>
<Trans>Password updated!</Trans>
</Text>
<Text style={[a.text_center, a.mx_auto, {maxWidth: '80%'}]}>
<Trans>You can now sign in with your new password.</Trans>
</Text>
<View style={[a.flex_row, a.justify_center]}>
<Button
onPress={onPressNext}
label={_(msg`Close alert`)}
accessibilityHint={_(msg`Closes password update alert`)}
variant="solid"
color="primary"
size="medium">
<ButtonText>
<Trans>Okay</Trans>
</ButtonText>
</Button>
</View>
</FormContainer>
)
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated'
export function ScreenTransition({children}: {children: React.ReactNode}) {
return (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
{children}
</Animated.View>
)
}
@@ -0,0 +1 @@
export {Fragment as ScreenTransition} from 'react'
+192
View File
@@ -0,0 +1,192 @@
import React, {useEffect, 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'
import {useAnalytics} from '#/lib/analytics/analytics'
import {isNetworkError} from '#/lib/strings/errors'
import {cleanError} from '#/lib/strings/errors'
import {checkAndFormatResetCode} from '#/lib/strings/password'
import {logger} from '#/logger'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError'
import * as TextField from '#/components/forms/TextField'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
import {Text} from '#/components/Typography'
import {FormContainer} from './FormContainer'
export const SetNewPasswordForm = ({
error,
serviceUrl,
setError,
onPressBack,
onPasswordSet,
}: {
error: string
serviceUrl: string
setError: (v: string) => void
onPressBack: () => void
onPasswordSet: () => void
}) => {
const {screen} = useAnalytics()
const {_} = useLingui()
const t = useTheme()
useEffect(() => {
screen('Signin:SetNewPasswordForm')
}, [screen])
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [resetCode, setResetCode] = useState<string>('')
const [password, setPassword] = useState<string>('')
const onPressNext = async () => {
// Check that the code is correct. We do this again just incase the user enters the code after their pw and we
// don't get to call onBlur first
const formattedCode = checkAndFormatResetCode(resetCode)
// TODO Better password strength check
if (!formattedCode || !password) {
setError(
_(
msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
),
)
return
}
setError('')
setIsProcessing(true)
try {
const agent = new BskyAgent({service: serviceUrl})
await agent.com.atproto.server.resetPassword({
token: formattedCode,
password,
})
onPasswordSet()
} catch (e: any) {
const errMsg = e.toString()
logger.warn('Failed to set new password', {error: e})
setIsProcessing(false)
if (isNetworkError(e)) {
setError(
_(
msg`Unable to contact your service. Please check your Internet connection.`,
),
)
} else {
setError(cleanError(errMsg))
}
}
}
const onBlur = () => {
const formattedCode = checkAndFormatResetCode(resetCode)
if (!formattedCode) {
setError(
_(
msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
),
)
return
}
setResetCode(formattedCode)
}
return (
<FormContainer
testID="setNewPasswordForm"
titleText={<Trans>Set new password</Trans>}>
<Text style={[a.leading_snug, a.mb_sm]}>
<Trans>
You will receive an email with a "reset code." Enter that code here,
then enter your new password.
</Trans>
</Text>
<View>
<TextField.LabelText>Reset code</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={Ticket} />
<TextField.Input
testID="resetCodeInput"
label={_(msg`Looks like XXXXX-XXXXX`)}
autoCapitalize="none"
autoFocus={true}
autoCorrect={false}
autoComplete="off"
value={resetCode}
onChangeText={setResetCode}
onFocus={() => setError('')}
onBlur={onBlur}
editable={!isProcessing}
accessibilityHint={_(
msg`Input code sent to your email for password reset`,
)}
/>
</TextField.Root>
</View>
<View>
<TextField.LabelText>New password</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={Lock} />
<TextField.Input
testID="newPasswordInput"
label={_(msg`Enter a password`)}
autoCapitalize="none"
autoCorrect={false}
autoComplete="password"
returnKeyType="done"
secureTextEntry={true}
textContentType="password"
clearButtonMode="while-editing"
value={password}
onChangeText={setPassword}
onSubmitEditing={onPressNext}
editable={!isProcessing}
accessibilityHint={_(msg`Input new password`)}
/>
</TextField.Root>
</View>
<FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_lg]}>
<Button
label={_(msg`Back`)}
variant="solid"
color="secondary"
size="medium"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
{isProcessing ? (
<ActivityIndicator />
) : (
<Button
label={_(msg`Next`)}
variant="solid"
color="primary"
size="medium"
onPress={onPressNext}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
</Button>
)}
{isProcessing ? (
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
<Trans>Updating...</Trans>
</Text>
) : undefined}
</View>
</FormContainer>
)
}
+178
View File
@@ -0,0 +1,178 @@
import React from 'react'
import {KeyboardAvoidingView} from 'react-native'
import {LayoutAnimationConfig} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {DEFAULT_SERVICE} from '#/lib/constants'
import {logger} from '#/logger'
import {useServiceQuery} from '#/state/queries/service'
import {SessionAccount, useSession} from '#/state/session'
import {useLoggedOutView} from '#/state/shell/logged-out'
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
import {ForgotPasswordForm} from '#/screens/Login/ForgotPasswordForm'
import {LoginForm} from '#/screens/Login/LoginForm'
import {PasswordUpdatedForm} from '#/screens/Login/PasswordUpdatedForm'
import {SetNewPasswordForm} from '#/screens/Login/SetNewPasswordForm'
import {atoms as a} from '#/alf'
import {ChooseAccountForm} from './ChooseAccountForm'
import {ScreenTransition} from './ScreenTransition'
enum Forms {
Login,
ChooseAccount,
ForgotPassword,
SetNewPassword,
PasswordUpdated,
}
export const Login = ({onPressBack}: {onPressBack: () => void}) => {
const {_} = useLingui()
const {accounts} = useSession()
const {track} = useAnalytics()
const {requestedAccountSwitchTo} = useLoggedOutView()
const requestedAccount = accounts.find(
acc => acc.did === requestedAccountSwitchTo,
)
const [error, setError] = React.useState<string>('')
const [serviceUrl, setServiceUrl] = React.useState<string>(
requestedAccount?.service || DEFAULT_SERVICE,
)
const [initialHandle, setInitialHandle] = React.useState<string>(
requestedAccount?.handle || '',
)
const [currentForm, setCurrentForm] = React.useState<Forms>(
requestedAccount
? Forms.Login
: accounts.length
? Forms.ChooseAccount
: Forms.Login,
)
const {
data: serviceDescription,
error: serviceError,
refetch: refetchService,
} = useServiceQuery(serviceUrl)
const onSelectAccount = (account?: SessionAccount) => {
if (account?.service) {
setServiceUrl(account.service)
}
setInitialHandle(account?.handle || '')
setCurrentForm(Forms.Login)
}
const gotoForm = (form: Forms) => {
setError('')
setCurrentForm(form)
}
React.useEffect(() => {
if (serviceError) {
setError(
_(
msg`Unable to contact your service. Please check your Internet connection.`,
),
)
logger.warn(`Failed to fetch service description for ${serviceUrl}`, {
error: String(serviceError),
})
} else {
setError('')
}
}, [serviceError, serviceUrl, _])
const onPressForgotPassword = () => {
track('Signin:PressedForgotPassword')
setCurrentForm(Forms.ForgotPassword)
}
let content = null
let title = ''
let description = ''
switch (currentForm) {
case Forms.Login:
title = _(msg`Sign in`)
description = _(msg`Enter your username and password`)
content = (
<LoginForm
error={error}
serviceUrl={serviceUrl}
serviceDescription={serviceDescription}
initialHandle={initialHandle}
setError={setError}
setServiceUrl={setServiceUrl}
onPressBack={() =>
accounts.length ? gotoForm(Forms.ChooseAccount) : onPressBack()
}
onPressForgotPassword={onPressForgotPassword}
onPressRetryConnect={refetchService}
/>
)
break
case Forms.ChooseAccount:
title = _(msg`Sign in`)
description = _(msg`Select from an existing account`)
content = (
<ChooseAccountForm
onSelectAccount={onSelectAccount}
onPressBack={onPressBack}
/>
)
break
case Forms.ForgotPassword:
title = _(msg`Forgot Password`)
description = _(msg`Let's get your password reset!`)
content = (
<ForgotPasswordForm
error={error}
serviceUrl={serviceUrl}
serviceDescription={serviceDescription}
setError={setError}
setServiceUrl={setServiceUrl}
onPressBack={() => gotoForm(Forms.Login)}
onEmailSent={() => gotoForm(Forms.SetNewPassword)}
/>
)
break
case Forms.SetNewPassword:
title = _(msg`Forgot Password`)
description = _(msg`Let's get your password reset!`)
content = (
<SetNewPasswordForm
error={error}
serviceUrl={serviceUrl}
setError={setError}
onPressBack={() => gotoForm(Forms.ForgotPassword)}
onPasswordSet={() => gotoForm(Forms.PasswordUpdated)}
/>
)
break
case Forms.PasswordUpdated:
title = _(msg`Password updated`)
description = _(msg`You can now sign in with your new password.`)
content = (
<PasswordUpdatedForm onPressNext={() => gotoForm(Forms.Login)} />
)
break
}
return (
<KeyboardAvoidingView testID="signIn" behavior="padding" style={a.flex_1}>
<LoggedOutLayout
leadin=""
title={title}
description={description}
scrollable>
<LayoutAnimationConfig skipEntering skipExiting>
<ScreenTransition key={currentForm}>{content}</ScreenTransition>
</LayoutAnimationConfig>
</LoggedOutLayout>
</KeyboardAvoidingView>
)
}
+41 -43
View File
@@ -1,51 +1,49 @@
import React from 'react'
import {View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {LABELS} from '@atproto/api'
import {useSafeAreaFrame} from 'react-native-safe-area-context'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {LABELS} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types'
import {CenteredView} from '#/view/com/util/Views'
import {ViewHeader} from '#/view/com/util/ViewHeader'
import {useAnalytics} from 'lib/analytics/analytics'
import {useSetMinimalShellMode} from '#/state/shell'
import {useSession} from '#/state/session'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {logger} from '#/logger'
import {
useMyLabelersQuery,
usePreferencesQuery,
UsePreferencesQueryResponse,
usePreferencesSetAdultContentMutation,
} from '#/state/queries/preferences'
import {
useProfileQuery,
useProfileUpdateMutation,
} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell'
import {useAnalytics} from 'lib/analytics/analytics'
import {ViewHeader} from '#/view/com/util/ViewHeader'
import {CenteredView} from '#/view/com/util/Views'
import {ScrollView} from '#/view/com/util/Views'
import {
UsePreferencesQueryResponse,
useMyLabelersQuery,
usePreferencesQuery,
usePreferencesSetAdultContentMutation,
} from '#/state/queries/preferences'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {logger} from '#/logger'
import {useTheme, atoms as a, useBreakpoints, ViewStyleProp} from '#/alf'
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {Props as SVGIconProps} from '#/components/icons/common'
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
import {Text} from '#/components/Typography'
import * as Toggle from '#/components/forms/Toggle'
import {InlineLink, Link} from '#/components/Link'
import {Button, ButtonText} from '#/components/Button'
import {Loader} from '#/components/Loader'
import * as LabelingService from '#/components/LabelingServiceCard'
import {GlobalModerationLabelPref} from '#/components/moderation/GlobalModerationLabelPref'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {Props as SVGIconProps} from '#/components/icons/common'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
import * as Dialog from '#/components/Dialog'
import {InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {GlobalLabelPreference} from '#/components/moderation/LabelPreference'
import {Text} from '#/components/Typography'
function ErrorState({error}: {error: string}) {
const t = useTheme()
@@ -352,17 +350,17 @@ export function ModerationScreenInner({
)}
{!isUnderage && adultContentEnabled && (
<>
<GlobalModerationLabelPref labelValueDefinition={LABELS.porn} />
<GlobalLabelPreference labelDefinition={LABELS.porn} />
<Divider />
<GlobalModerationLabelPref labelValueDefinition={LABELS.sexual} />
<GlobalLabelPreference labelDefinition={LABELS.sexual} />
<Divider />
<GlobalModerationLabelPref
labelValueDefinition={LABELS['graphic-media']}
<GlobalLabelPreference
labelDefinition={LABELS['graphic-media']}
/>
<Divider />
</>
)}
<GlobalModerationLabelPref labelValueDefinition={LABELS.nudity} />
<GlobalLabelPreference labelDefinition={LABELS.nudity} />
</View>
</View>
@@ -520,11 +518,11 @@ function PwiOptOut() {
msg`Discourage apps from showing my account to logged-out users`,
)}>
<Toggle.Switch />
<Toggle.Label style={[a.text_md, a.flex_1]}>
<Toggle.LabelText style={[a.text_md, a.flex_1]}>
<Trans>
Discourage apps from showing my account to logged-out users
</Trans>
</Toggle.Label>
</Toggle.LabelText>
</Toggle.Item>
{updateProfile.isPending && <Loader />}
@@ -547,9 +545,9 @@ function PwiOptOut() {
</Trans>
</Text>
<InlineLink to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">
<InlineLinkText to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">
<Trans>Learn more about what is public on Bluesky.</Trans>
</InlineLink>
</InlineLinkText>
</View>
</View>
)
+18 -20
View File
@@ -1,29 +1,27 @@
import React from 'react'
import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {IS_DEV} from '#/env'
import {isWeb} from '#/platform/detection'
import {useOnboardingDispatch} from '#/state/shell'
import {
useTheme,
atoms as a,
useBreakpoints,
web,
native,
flatten,
TextStyleProp,
} from '#/alf'
import {P, leading, Text} from '#/components/Typography'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {Button, ButtonIcon} from '#/components/Button'
import {ScrollView} from '#/view/com/util/Views'
import {createPortalGroup} from '#/components/Portal'
import {Context} from '#/screens/Onboarding/state'
import {
atoms as a,
flatten,
native,
TextStyleProp,
useBreakpoints,
useTheme,
web,
} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {createPortalGroup} from '#/components/Portal'
import {leading, P, Text} from '#/components/Typography'
import {IS_DEV} from '#/env'
const COL_WIDTH = 500
@@ -75,7 +73,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
onPress={() => onboardDispatch({type: 'skip'})}
// DEV ONLY
label="Clear onboarding state">
Clear
<ButtonText>Clear</ButtonText>
</Button>
</View>
)}
@@ -203,7 +201,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
)
}
export function Title({
export function TitleText({
children,
style,
}: React.PropsWithChildren<TextStyleProp>) {
@@ -223,7 +221,7 @@ export function Title({
)
}
export function Description({
export function DescriptionText({
children,
style,
}: React.PropsWithChildren<TextStyleProp>) {
@@ -1,18 +1,17 @@
import React from 'react'
import {View} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {Image} from 'expo-image'
import {useLingui} from '@lingui/react'
import {LinearGradient} from 'expo-linear-gradient'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useTheme, atoms as a} from '#/alf'
import * as Toggle from '#/components/forms/Toggle'
import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
import {Text} from '#/components/Typography'
import {RichText} from '#/components/RichText'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
import {FeedConfig} from '#/screens/Onboarding/StepAlgoFeeds'
import {atoms as a, useTheme} from '#/alf'
import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
function PrimaryFeedCardInner({
feed,
+26 -35
View File
@@ -1,26 +1,26 @@
import React from 'react'
import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {IS_PROD} from '#/env'
import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {
DescriptionText,
OnboardingControls,
TitleText,
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
import {atoms as a, tokens, useTheme, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle'
import {Text} from '#/components/Typography'
import {Loader} from '#/components/Loader'
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
import {useAnalytics} from '#/lib/analytics/analytics'
import {Context} from '#/screens/Onboarding/state'
import {
Title,
Description,
OnboardingControls,
} from '#/screens/Onboarding/Layout'
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
import {IconCircle} from '#/components/IconCircle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {IS_PROD} from '#/env'
export type FeedConfig = {
default: boolean
@@ -34,11 +34,6 @@ export const PRIMARY_FEEDS: FeedConfig[] = [
uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot',
gradient: tokens.gradients.midnight,
},
{
default: IS_PROD, // these feeds are only available in prod
uri: 'at://did:plc:wqowuobffl66jv3kpsvo7ak4/app.bsky.feed.generator/the-algorithm',
gradient: tokens.gradients.midnight,
},
]
const SECONDARY_FEEDS: FeedConfig[] = [
@@ -90,6 +85,12 @@ export function StepAlgoFeeds() {
selectedSecondaryFeeds: secondaryFeedUris,
selectedSecondaryFeedsLength: secondaryFeedUris.length,
})
logEvent('onboarding:algoFeeds:nextPressed', {
selectedPrimaryFeeds: primaryFeedUris,
selectedPrimaryFeedsLength: primaryFeedUris.length,
selectedSecondaryFeeds: secondaryFeedUris,
selectedSecondaryFeedsLength: secondaryFeedUris.length,
})
}, [primaryFeedUris, secondaryFeedUris, dispatch, track])
React.useEffect(() => {
@@ -100,15 +101,15 @@ export function StepAlgoFeeds() {
<View style={[a.align_start, gtMobile ? a.px_5xl : a.px_xl]}>
<IconCircle icon={ListSparkle} style={[a.mb_2xl]} />
<Title>
<TitleText>
<Trans>Choose your main feeds</Trans>
</Title>
<Description>
</TitleText>
<DescriptionText>
<Trans>
Custom feeds built by the community bring you new experiences and help
you find the content you love.
</Trans>
</Description>
</DescriptionText>
<View style={[a.w_full, a.pb_2xl]}>
<Toggle.Group
@@ -125,16 +126,6 @@ export function StepAlgoFeeds() {
<Trans>We recommend our "Discover" feed:</Trans>
</Text>
<FeedCard config={PRIMARY_FEEDS[0]} />
<Text
style={[
a.text_md,
a.pt_4xl,
a.pb_lg,
t.atoms.text_contrast_medium,
]}>
<Trans>We also think you'll like "For You" by Skygaze:</Trans>
</Text>
<FeedCard config={PRIMARY_FEEDS[1]} />
</Toggle.Group>
<Toggle.Group
+26 -22
View File
@@ -1,34 +1,35 @@
import React from 'react'
import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {BSKY_APP_ACCOUNT_DID} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
import {Text} from '#/components/Typography'
import {useOnboardingDispatch} from '#/state/shell'
import {Loader} from '#/components/Loader'
import {useSetSaveFeedsMutation} from '#/state/queries/preferences'
import {getAgent} from '#/state/session'
import {useAnalytics} from '#/lib/analytics/analytics'
import {Context} from '#/screens/Onboarding/state'
import {useOnboardingDispatch} from '#/state/shell'
import {
Title,
Description,
DescriptionText,
OnboardingControls,
TitleText,
} from '#/screens/Onboarding/Layout'
import {IconCircle} from '#/components/IconCircle'
import {uploadBlob} from 'lib/api'
import {Context} from '#/screens/Onboarding/state'
import {
bulkWriteFollows,
sortPrimaryAlgorithmFeeds,
} from '#/screens/Onboarding/util'
import {atoms as a, useTheme, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {IconCircle} from '#/components/IconCircle'
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'
import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function StepFinished() {
const {_} = useLingui()
@@ -59,7 +60,9 @@ export function StepFinished() {
try {
await Promise.all([
bulkWriteFollows(suggestedAccountsStepResults.accountDids),
bulkWriteFollows(
suggestedAccountsStepResults.accountDids.concat(BSKY_APP_ACCOUNT_DID),
),
// these must be serial
(async () => {
await getAgent().setInterestsPref({tags: selectedInterests})
@@ -98,7 +101,8 @@ export function StepFinished() {
onboardDispatch({type: 'finish'})
track('OnboardingV2:StepFinished:End')
track('OnboardingV2:Complete')
}, [dispatch, onboardDispatch, saveFeeds, state, track])
logEvent('onboarding:finished:nextPressed', {})
}, [state, dispatch, onboardDispatch, setSaving, saveFeeds, track])
React.useEffect(() => {
track('OnboardingV2:StepFinished:Start')
@@ -108,12 +112,12 @@ export function StepFinished() {
<View style={[a.align_start, gtMobile ? a.px_5xl : a.px_xl]}>
<IconCircle icon={Check} style={[a.mb_2xl]} />
<Title>
<TitleText>
<Trans>You're ready to go!</Trans>
</Title>
<Description>
</TitleText>
<DescriptionText>
<Trans>We hope you have a wonderful time. Remember, Bluesky is:</Trans>
</Description>
</DescriptionText>
<View style={[a.pt_5xl, a.gap_3xl]}>
<View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
+22 -21
View File
@@ -1,28 +1,28 @@
import React from 'react'
import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints} from '#/alf'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
import {useAnalytics} from '#/lib/analytics/analytics'
import {Context} from '#/screens/Onboarding/state'
import {
Title,
Description,
OnboardingControls,
} from '#/screens/Onboarding/Layout'
import {logEvent} from '#/lib/statsig/statsig'
import {
usePreferencesQuery,
useSetFeedViewPreferencesMutation,
} from 'state/queries/preferences'
import {
DescriptionText,
OnboardingControls,
TitleText,
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
import {Text} from '#/components/Typography'
export function StepFollowingFeed() {
const {_} = useLingui()
@@ -47,6 +47,7 @@ export function StepFollowingFeed() {
const onContinue = React.useCallback(() => {
dispatch({type: 'next'})
track('OnboardingV2:StepFollowingFeed:End')
logEvent('onboarding:followingFeed:nextPressed', {})
}, [track, dispatch])
React.useEffect(() => {
@@ -58,12 +59,12 @@ export function StepFollowingFeed() {
<View style={[a.align_start, gtMobile ? a.px_5xl : a.px_xl]}>
<IconCircle icon={FilterTimeline} style={[a.mb_2xl]} />
<Title>
<TitleText>
<Trans>Your default feed is "Following"</Trans>
</Title>
<Description style={[a.mb_md]}>
</TitleText>
<DescriptionText style={[a.mb_md]}>
<Trans>It shows posts from the people you follow as they happen.</Trans>
</Description>
</DescriptionText>
<View style={[a.w_full]}>
<Toggle.Item
@@ -139,9 +140,9 @@ export function StepFollowingFeed() {
</Toggle.Item>
</View>
<Description style={[a.mt_lg]}>
<DescriptionText style={[a.mt_lg]}>
<Trans>You can change these settings later.</Trans>
</Description>
</DescriptionText>
<OnboardingControls.Portal>
<Button
+23 -19
View File
@@ -1,32 +1,32 @@
import React from 'react'
import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQuery} from '@tanstack/react-query'
import {logger} from '#/logger'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Loader} from '#/components/Loader'
import * as Toggle from '#/components/forms/Toggle'
import {getAgent} from '#/state/session'
import {useAnalytics} from '#/lib/analytics/analytics'
import {Text} from '#/components/Typography'
import {useOnboardingDispatch} from '#/state/shell'
import {logEvent} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize'
import {Context, ApiResponseMap} from '#/screens/Onboarding/state'
import {logger} from '#/logger'
import {getAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {
Title,
Description,
DescriptionText,
OnboardingControls,
TitleText,
} from '#/screens/Onboarding/Layout'
import {ApiResponseMap, Context} from '#/screens/Onboarding/state'
import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function StepInterests() {
const {_} = useLingui()
@@ -107,6 +107,10 @@ export function StepInterests() {
selectedInterests: interests,
selectedInterestsLength: interests.length,
})
logEvent('onboarding:interests:nextPressed', {
selectedInterests: interests,
selectedInterestsLength: interests.length,
})
} catch (e: any) {
logger.info(`onboading: error saving interests`)
logger.error(e)
@@ -161,8 +165,8 @@ export function StepInterests() {
]}
/>
<Title>{title}</Title>
<Description>{description}</Description>
<TitleText>{title}</TitleText>
<DescriptionText>{description}</DescriptionText>
<View style={[a.w_full, a.pt_2xl]}>
{isLoading ? (
@@ -1,18 +1,18 @@
import React from 'react'
import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {UseMutateFunction} from '@tanstack/react-query'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {usePreferencesQuery} from '#/state/queries/preferences'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {logger} from '#/logger'
import {Text} from '#/components/Typography'
import * as Toggle from '#/components/forms/Toggle'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import * as Prompt from '#/components/Prompt'
import {isIOS} from '#/platform/detection'
import {Text} from '#/components/Typography'
function Card({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
@@ -113,19 +113,17 @@ export function AdultContentEnabledPref({
)}
<Prompt.Outer control={prompt}>
<Prompt.Title>
<Prompt.TitleText>
<Trans>Adult Content</Trans>
</Prompt.Title>
<Prompt.Description>
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>
Due to Apple policies, adult content can only be enabled on the web
after completing sign up.
</Trans>
</Prompt.Description>
</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action onPress={() => prompt.close()}>
<Trans>OK</Trans>
</Prompt.Action>
<Prompt.Action onPress={() => prompt.close()} cta={_(msg`OK`)} />
</Prompt.Actions>
</Prompt.Outer>
</>
@@ -1,17 +1,17 @@
import React from 'react'
import {View} from 'react-native'
import {LabelPreference, InterpretedLabelValueDefinition} from '@atproto/api'
import {useLingui} from '@lingui/react'
import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
import {
usePreferencesQuery,
usePreferencesSetContentLabelMutation,
} from '#/state/queries/preferences'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import * as ToggleButton from '#/components/forms/ToggleButton'
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
import {Text} from '#/components/Typography'
export function ModerationOption({
labelValueDefinition,
@@ -83,13 +83,13 @@ export function ModerationOption({
values={[visibility ?? 'hide']}
onChange={onChange}>
<ToggleButton.Button name="ignore" label={labels.show}>
{labels.show}
<ToggleButton.ButtonText>{labels.show}</ToggleButton.ButtonText>
</ToggleButton.Button>
<ToggleButton.Button name="warn" label={labels.warn}>
{labels.warn}
<ToggleButton.ButtonText>{labels.warn}</ToggleButton.ButtonText>
</ToggleButton.Button>
<ToggleButton.Button name="hide" label={labels.hide}>
{labels.hide}
<ToggleButton.ButtonText>{labels.hide}</ToggleButton.ButtonText>
</ToggleButton.Button>
</ToggleButton.Group>
)}
+19 -18
View File
@@ -1,27 +1,27 @@
import React from 'react'
import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {LABELS} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints} from '#/alf'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {usePreferencesSetAdultContentMutation} from 'state/queries/preferences'
import {
DescriptionText,
OnboardingControls,
TitleText,
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref'
import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {IconCircle} from '#/components/IconCircle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {Loader} from '#/components/Loader'
import {useAnalytics} from '#/lib/analytics/analytics'
import {
Description,
OnboardingControls,
Title,
} from '#/screens/Onboarding/Layout'
import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption'
import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref'
import {Context} from '#/screens/Onboarding/state'
import {IconCircle} from '#/components/IconCircle'
export function StepModeration() {
const {_} = useLingui()
@@ -46,6 +46,7 @@ export function StepModeration() {
const onContinue = React.useCallback(() => {
dispatch({type: 'next'})
track('OnboardingV2:StepModeration:End')
logEvent('onboarding:moderation:nextPressed', {})
}, [track, dispatch])
React.useEffect(() => {
@@ -56,14 +57,14 @@ export function StepModeration() {
<View style={[a.align_start, gtMobile ? a.px_5xl : a.px_xl]}>
<IconCircle icon={EyeSlash} style={[a.mb_2xl]} />
<Title>
<TitleText>
<Trans>You're in control</Trans>
</Title>
<Description style={[a.mb_xl]}>
</TitleText>
<DescriptionText style={[a.mb_xl]}>
<Trans>
Select what you want to see (or not see), and we’ll handle the rest.
</Trans>
</Description>
</DescriptionText>
{!preferences ? (
<View style={[a.pt_md]}>
+7 -7
View File
@@ -8,8 +8,8 @@ import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {StreamingLive_Stroke2_Corner0_Rounded as StreamingLive} from '#/components/icons/StreamingLive'
import {Context} from '#/screens/Onboarding/state'
import {
Title,
Description,
TitleText,
DescriptionText,
OnboardingControls,
} from '#/screens/Onboarding/Layout'
import {Emoji, emojiItems, AvatarColor, avatarColors} from './types'
@@ -165,15 +165,15 @@ export function StepProfile() {
<View style={[a.align_start, t.atoms.bg, a.justify_between]}>
<View style={[gtMobile ? a.px_5xl : a.px_xl]}>
<IconCircle icon={StreamingLive} style={[a.mb_2xl]} />
<Title>
<TitleText>
<Trans>Give your profile a face</Trans>
</Title>
<Description>
</TitleText>
<DescriptionText>
<Trans>
Help people know you're not a bot by uploading a picture or
creating an avatar.
</Trans>
</Description>
</DescriptionText>
</View>
<View style={[a.w_full, a.align_center, {paddingTop: 80}]}>
<AvatarCircle
@@ -218,7 +218,7 @@ export function StepProfile() {
<Dialog.Outer control={creatorControl}>
<Dialog.Handle />
<Dialog.Inner label="Avatar creator" noHorizontalPadding>
<Dialog.Inner label="Avatar creator">
<View style={[a.align_center, {paddingTop: 20}]}>
<AvatarCreatorCircle avatar={avatar} />
</View>
@@ -1,33 +1,33 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints} from '#/alf'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
import {useProfilesQuery} from '#/state/queries/profile'
import {Loader} from '#/components/Loader'
import * as Toggle from '#/components/forms/Toggle'
import {useModerationOpts} from '#/state/queries/preferences'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize'
import {Context} from '#/screens/Onboarding/state'
import {useModerationOpts} from '#/state/queries/preferences'
import {useProfilesQuery} from '#/state/queries/profile'
import {
Title,
Description,
DescriptionText,
OnboardingControls,
TitleText,
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {
SuggestedAccountCard,
SuggestedAccountCardPlaceholder,
} from '#/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard'
import {aggregateInterestItems} from '#/screens/Onboarding/util'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function Inner({
profiles,
@@ -110,12 +110,20 @@ export function StepSuggestedAccounts() {
track('OnboardingV2:StepSuggestedAccounts:End', {
selectedAccountsLength: dids.length,
})
logEvent('onboarding:suggestedAccounts:nextPressed', {
selectedAccountsLength: dids.length,
skipped: false,
})
}, [dids, setSaving, dispatch, track])
const handleSkip = React.useCallback(() => {
// if a user comes back and clicks skip, erase follows
dispatch({type: 'setSuggestedAccountsStepResults', accountDids: []})
dispatch({type: 'next'})
logEvent('onboarding:suggestedAccounts:nextPressed', {
selectedAccountsLength: 0,
skipped: true,
})
}, [dispatch])
const isLoading = isProfilesLoading && moderationOpts
@@ -128,16 +136,16 @@ export function StepSuggestedAccounts() {
<View style={[a.align_start, gtMobile ? a.px_5xl : a.px_xl]}>
<IconCircle icon={At} style={[a.mb_2xl]} />
<Title>
<TitleText>
<Trans>Here are some accounts for you to follow</Trans>
</Title>
<Description>
</TitleText>
<DescriptionText>
{state.interestsStepResults.selectedInterests.length ? (
<Trans>Based on your interest in {interestsText}</Trans>
) : (
<Trans>These are popular accounts you might like:</Trans>
)}
</Description>
</DescriptionText>
<View style={[a.w_full, a.pt_xl]}>
{isLoading ? (
+25 -21
View File
@@ -1,28 +1,28 @@
import React from 'react'
import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints} from '#/alf'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle'
import {Loader} from '#/components/Loader'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize'
import {Context} from '#/screens/Onboarding/state'
import {
Title,
Description,
OnboardingControls,
} from '#/screens/Onboarding/Layout'
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
import {aggregateInterestItems} from '#/screens/Onboarding/util'
import {IconCircle} from '#/components/IconCircle'
import {IS_TEST_USER} from 'lib/constants'
import {useSession} from 'state/session'
import {
DescriptionText,
OnboardingControls,
TitleText,
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
import {aggregateInterestItems} from '#/screens/Onboarding/util'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass'
import {Loader} from '#/components/Loader'
export function StepTopicalFeeds() {
const {_} = useLingui()
@@ -63,6 +63,10 @@ export function StepTopicalFeeds() {
selectedFeeds: selectedFeedUris,
selectedFeedsLength: selectedFeedUris.length,
})
logEvent('onboarding:topicalFeeds:nextPressed', {
selectedFeeds: selectedFeedUris,
selectedFeedsLength: selectedFeedUris.length,
})
}, [selectedFeedUris, dispatch, track])
React.useEffect(() => {
@@ -73,10 +77,10 @@ export function StepTopicalFeeds() {
<View style={[a.align_start, gtMobile ? a.px_5xl : a.px_xl]}>
<IconCircle icon={ListMagnifyingGlass} style={[a.mb_2xl]} />
<Title>
<TitleText>
<Trans>Feeds can be topical as well!</Trans>
</Title>
<Description>
</TitleText>
<DescriptionText>
{state.interestsStepResults.selectedInterests.length ? (
<Trans>
Here are some topical feeds based on your interests: {interestsText}
@@ -88,7 +92,7 @@ export function StepTopicalFeeds() {
many as you like.
</Trans>
)}
</Description>
</DescriptionText>
<View style={[a.w_full, a.pb_2xl, a.pt_2xl]}>
<Toggle.Group
+4 -3
View File
@@ -1,10 +1,10 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {isInvalidHandle} from 'lib/strings/handles'
import {Shadow} from '#/state/cache/types'
import {Trans} from '@lingui/macro'
import {Shadow} from '#/state/cache/types'
import {isInvalidHandle} from 'lib/strings/handles'
import {atoms as a, useTheme, web} from '#/alf'
import {Text} from '#/components/Typography'
@@ -26,6 +26,7 @@ export function ProfileHeaderHandle({
</View>
) : undefined}
<Text
numberOfLines={1}
style={[
invalidHandle
? [
@@ -36,7 +37,7 @@ export function ProfileHeaderHandle({
a.rounded_xs,
{borderColor: t.palette.contrast_200},
]
: [a.text_md, t.atoms.text_contrast_medium],
: [a.text_md, a.leading_tight, t.atoms.text_contrast_medium],
web({wordBreak: 'break-all'}),
]}>
{invalidHandle ? <Trans>⚠Invalid Handle</Trans> : `@${profile.handle}`}
+7 -8
View File
@@ -1,17 +1,16 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {Trans, msg} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {Shadow} from '#/state/cache/types'
import {pluralize} from '#/lib/strings/helpers'
import {Shadow} from '#/state/cache/types'
import {makeProfileLink} from 'lib/routes/links'
import {formatCount} from 'view/com/util/numeric/format'
import {atoms as a, useTheme} from '#/alf'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
import {InlineLink} from '#/components/Link'
export function ProfileHeaderMetrics({
profile,
@@ -28,7 +27,7 @@ export function ProfileHeaderMetrics({
<View
style={[a.flex_row, a.gap_sm, a.align_center, a.pb_md]}
pointerEvents="box-none">
<InlineLink
<InlineLinkText
testID="profileHeaderFollowersButton"
style={[a.flex_row, t.atoms.text]}
to={makeProfileLink(profile, 'followers')}
@@ -37,8 +36,8 @@ export function ProfileHeaderMetrics({
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
{pluralizedFollowers}
</Text>
</InlineLink>
<InlineLink
</InlineLinkText>
<InlineLinkText
testID="profileHeaderFollowsButton"
style={[a.flex_row, t.atoms.text]}
to={makeProfileLink(profile, 'follows')}
@@ -49,7 +48,7 @@ export function ProfileHeaderMetrics({
following
</Text>
</Trans>
</InlineLink>
</InlineLinkText>
<Text style={[a.font_bold, t.atoms.text, a.text_md]}>
{formatCount(profile.postsCount || 0)}{' '}
<Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}>
@@ -3,43 +3,42 @@ import {View} from 'react-native'
import {
AppBskyActorDefs,
AppBskyLabelerDefs,
ModerationOpts,
moderateProfile,
ModerationOpts,
RichText as RichTextAPI,
} from '@atproto/api'
import {Trans, msg} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {RichText} from '#/components/RichText'
import {useModalControls} from '#/state/modals'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAnalytics} from 'lib/analytics/analytics'
import {useSession} from '#/state/session'
import {isAppLabeler} from '#/lib/moderation'
import {pluralize} from '#/lib/strings/helpers'
import {logger} from '#/logger'
import {Shadow} from '#/state/cache/types'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {useModalControls} from '#/state/modals'
import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
import {logger} from '#/logger'
import {Haptics} from '#/lib/haptics'
import {pluralize} from '#/lib/strings/helpers'
import {isAppLabeler} from '#/lib/moderation'
import {atoms as a, useTheme, tokens} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
import * as Toast from '#/view/com/util/Toast'
import {ProfileHeaderShell} from './Shell'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useRequireAuth, useSession} from '#/state/session'
import {useAnalytics} from 'lib/analytics/analytics'
import {useHaptics} from 'lib/haptics'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {DialogOuterProps} from '#/components/Dialog'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
Heart2_Stroke2_Corner0_Rounded as Heart,
} from '#/components/icons/Heart2'
import {Link} from '#/components/Link'
import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {ProfileHeaderDisplayName} from './DisplayName'
import {ProfileHeaderHandle} from './Handle'
import {ProfileHeaderMetrics} from './Metrics'
import {
Heart2_Stroke2_Corner0_Rounded as Heart,
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
} from '#/components/icons/Heart2'
import {DialogOuterProps} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
import {Link} from '#/components/Link'
import {ProfileHeaderShell} from './Shell'
interface Props {
profile: AppBskyActorDefs.ProfileViewDetailed
@@ -65,6 +64,8 @@ let ProfileHeaderLabeler = ({
const {currentAccount, hasSession} = useSession()
const {openModal} = useModalControls()
const {track} = useAnalytics()
const requireAuth = useRequireAuth()
const playHaptic = useHaptics()
const cantSubscribePrompt = Prompt.usePromptControl()
const isSelf = currentAccount?.did === profile.did
@@ -94,7 +95,7 @@ let ProfileHeaderLabeler = ({
return
}
try {
Haptics.default()
playHaptic()
if (likeUri) {
await unlikeMod({uri: likeUri})
@@ -115,7 +116,7 @@ let ProfileHeaderLabeler = ({
)
logger.error(`Failed to toggle labeler like`, {message: e.message})
}
}, [labeler, likeUri, likeMod, unlikeMod, track, _])
}, [labeler, playHaptic, likeUri, unlikeMod, track, likeMod, _])
const onPressEditProfile = React.useCallback(() => {
track('ProfileHeader:EditProfileButtonClicked')
@@ -125,27 +126,32 @@ let ProfileHeaderLabeler = ({
})
}, [track, openModal, profile])
const onPressSubscribe = React.useCallback(async () => {
if (!canSubscribe) {
cantSubscribePrompt.open()
return
}
try {
await toggleSubscription({
did: profile.did,
subscribe: !isSubscribed,
})
} catch (e: any) {
// setSubscriptionError(e.message)
logger.error(`Failed to subscribe to labeler`, {message: e.message})
}
}, [
toggleSubscription,
isSubscribed,
profile,
canSubscribe,
cantSubscribePrompt,
])
const onPressSubscribe = React.useCallback(
() =>
requireAuth(async () => {
if (!canSubscribe) {
cantSubscribePrompt.open()
return
}
try {
await toggleSubscription({
did: profile.did,
subscribe: !isSubscribed,
})
} catch (e: any) {
// setSubscriptionError(e.message)
logger.error(`Failed to subscribe to labeler`, {message: e.message})
}
}),
[
requireAuth,
toggleSubscription,
isSubscribed,
profile,
canSubscribe,
cantSubscribePrompt,
],
)
const isMe = React.useMemo(
() => currentAccount?.did === profile.did,
@@ -184,7 +190,6 @@ let ProfileHeaderLabeler = ({
? _(msg`Unsubscribe from this labeler`)
: _(msg`Subscribe to this labeler`)
}
disabled={!hasSession}
onPress={onPressSubscribe}>
{state => (
<View
@@ -243,6 +248,8 @@ let ProfileHeaderLabeler = ({
style={[a.text_md]}
numberOfLines={15}
value={descriptionRT}
enableTags
authorHandle={profile.handle}
/>
</View>
) : undefined}
@@ -312,17 +319,18 @@ function CantSubscribePrompt({
}: {
control: DialogOuterProps['control']
}) {
const {_} = useLingui()
return (
<Prompt.Outer control={control}>
<Prompt.Title>Unable to subscribe</Prompt.Title>
<Prompt.Description>
<Prompt.TitleText>Unable to subscribe</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>
We're sorry! You can only subscribe to ten labelers, and you've
reached your limit of ten.
</Trans>
</Prompt.Description>
</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action onPress={control.close}>OK</Prompt.Action>
<Prompt.Action onPress={control.close} cta={_(msg`OK`)} />
</Prompt.Actions>
</Prompt.Outer>
)
@@ -2,39 +2,40 @@ import React, {memo, useMemo} from 'react'
import {View} from 'react-native'
import {
AppBskyActorDefs,
ModerationOpts,
moderateProfile,
ModerationOpts,
RichText as RichTextAPI,
} from '@atproto/api'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {useAnalytics} from 'lib/analytics/analytics'
import {useSession, useRequireAuth} from '#/state/session'
import {Shadow} from '#/state/cache/types'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {
useProfileFollowMutationQueue,
useProfileBlockMutationQueue,
} from '#/state/queries/profile'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
import {useModalControls} from '#/state/modals'
import {
useProfileBlockMutationQueue,
useProfileFollowMutationQueue,
} from '#/state/queries/profile'
import {useRequireAuth, useSession} from '#/state/session'
import {useAnalytics} from 'lib/analytics/analytics'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
import * as Toast from '#/view/com/util/Toast'
import {ProfileHeaderShell} from './Shell'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} 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'
import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import {ProfileHeaderDisplayName} from './DisplayName'
import {ProfileHeaderHandle} from './Handle'
import {ProfileHeaderMetrics} from './Metrics'
import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
import {RichText} from '#/components/RichText'
import * as Prompt from '#/components/Prompt'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {ProfileHeaderShell} from './Shell'
interface Props {
profile: AppBskyActorDefs.ProfileViewDetailed
@@ -79,6 +80,9 @@ let ProfileHeaderStandard = ({
})
}, [track, openModal, profile])
const autoExpandSuggestionsOnProfileFollow = useGate(
'autoexpand_suggestions_on_profile_follow',
)
const onPressFollow = () => {
requireAuth(async () => {
try {
@@ -92,6 +96,9 @@ let ProfileHeaderStandard = ({
)}`,
),
)
if (isWeb && autoExpandSuggestionsOnProfileFollow) {
setShowSuggestedFollows(true)
}
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to follow', {message: String(e)})
@@ -213,7 +220,6 @@ let ProfileHeaderStandard = ({
? _(msg`Unfollow ${profile.handle}`)
: _(msg`Follow ${profile.handle}`)
}
disabled={!hasSession}
onPress={
profile.viewer?.following ? onPressUnfollow : onPressFollow
}
@@ -248,6 +254,8 @@ let ProfileHeaderStandard = ({
style={[a.text_md]}
numberOfLines={15}
value={descriptionRT}
enableTags
authorHandle={profile.handle}
/>
</View>
) : undefined}
+11 -14
View File
@@ -1,23 +1,22 @@
import React, {memo} from 'react'
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {NavigationProp} from 'lib/routes/types'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {BACK_HITSLOP} from 'lib/constants'
import {useSession} from '#/state/session'
import {Shadow} from '#/state/cache/types'
import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
import {useNavigation} from '@react-navigation/native'
import {atoms as a, useTheme} from '#/alf'
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
import {BlurView} from 'view/com/util/BlurView'
import {Shadow} from '#/state/cache/types'
import {ProfileImageLightbox, useLightboxControls} from '#/state/lightbox'
import {useSession} from '#/state/session'
import {BACK_HITSLOP} from 'lib/constants'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types'
import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {UserBanner} from 'view/com/util/UserBanner'
import {atoms as a, useTheme} from '#/alf'
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
interface Props {
@@ -97,9 +96,7 @@ let ProfileHeaderShell = ({
accessibilityLabel={_(msg`Back`)}
accessibilityHint="">
<View style={styles.backBtnWrapper}>
<BlurView style={styles.backBtn} blurType="dark">
<FontAwesomeIcon size={18} icon="angle-left" color="white" />
</BlurView>
<FontAwesomeIcon size={18} icon="angle-left" color="white" />
</View>
</TouchableWithoutFeedback>
)}
+4 -17
View File
@@ -4,13 +4,11 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {makeRecordUri} from '#/lib/strings/url-helpers'
import {useSetMinimalShellMode} from '#/state/shell'
import {ViewHeader} from '#/view/com/util/ViewHeader'
import {LikedByList} from '#/components/LikedByList'
import {useSetMinimalShellMode} from '#/state/shell'
import {makeRecordUri} from '#/lib/strings/url-helpers'
import {atoms as a, useBreakpoints} from '#/alf'
export function ProfileLabelerLikedByScreen({
route,
@@ -19,7 +17,6 @@ export function ProfileLabelerLikedByScreen({
const {name: handleOrDid} = route.params
const uri = makeRecordUri(handleOrDid, 'app.bsky.labeler.service', 'self')
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
useFocusEffect(
React.useCallback(() => {
@@ -28,17 +25,7 @@ export function ProfileLabelerLikedByScreen({
)
return (
<View
style={[
a.mx_auto,
a.w_full,
a.h_full_vh,
gtMobile && [
{
maxWidth: 600,
},
],
]}>
<View style={{flex: 1}}>
<ViewHeader title={_(msg`Liked By`)} />
<LikedByList uri={uri} />
</View>
+25 -9
View File
@@ -1,18 +1,19 @@
import React from 'react'
import {View} from 'react-native'
import {findNodeHandle, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ListRef} from 'view/com/util/List'
import {Feed} from 'view/com/posts/Feed'
import {EmptyState} from 'view/com/util/EmptyState'
import {useQueryClient} from '@tanstack/react-query'
import {isNative} from '#/platform/detection'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {useQueryClient} from '@tanstack/react-query'
import {truncateAndInvalidate} from '#/state/queries/util'
import {Text} from '#/view/com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {isNative} from '#/platform/detection'
import {Text} from '#/view/com/util/text/Text'
import {Feed} from 'view/com/posts/Feed'
import {EmptyState} from 'view/com/util/EmptyState'
import {ListRef} from 'view/com/util/List'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {SectionRef} from './types'
interface FeedSectionProps {
@@ -21,12 +22,20 @@ interface FeedSectionProps {
isFocused: boolean
scrollElRef: ListRef
ignoreFilterFor?: string
setScrollViewTag: (tag: number | null) => void
}
export const ProfileFeedSection = React.forwardRef<
SectionRef,
FeedSectionProps
>(function FeedSectionImpl(
{feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor},
{
feed,
headerHeight,
isFocused,
scrollElRef,
ignoreFilterFor,
setScrollViewTag,
},
ref,
) {
const {_} = useLingui()
@@ -50,6 +59,13 @@ export const ProfileFeedSection = React.forwardRef<
return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} />
}, [_])
React.useEffect(() => {
if (isFocused && scrollElRef.current) {
const nativeTag = findNodeHandle(scrollElRef.current)
setScrollViewTag(nativeTag)
}
}, [isFocused, scrollElRef, setScrollViewTag])
return (
<View>
<Feed
+27 -17
View File
@@ -1,30 +1,29 @@
import React from 'react'
import {View} from 'react-native'
import {findNodeHandle, View} from 'react-native'
import {useSafeAreaFrame} from 'react-native-safe-area-context'
import {
AppBskyLabelerDefs,
ModerationOpts,
interpretLabelValueDefinitions,
InterpretedLabelValueDefinition,
interpretLabelValueDefinitions,
ModerationOpts,
} from '@atproto/api'
import {Trans, msg} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSafeAreaFrame} from 'react-native-safe-area-context'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation'
import {ListRef} from '#/view/com/util/List'
import {SectionRef} from './types'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {isNative} from '#/platform/detection'
import {useTheme, atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
import {Loader} from '#/components/Loader'
import {Divider} from '#/components/Divider'
import {ListRef} from '#/view/com/util/List'
import {CenteredView, ScrollView} from '#/view/com/util/Views'
import {ErrorState} from '../ErrorState'
import {ModerationLabelPref} from '#/components/moderation/ModerationLabelPref'
import {atoms as a, useTheme} from '#/alf'
import {Divider} from '#/components/Divider'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader'
import {LabelerLabelPreference} from '#/components/moderation/LabelPreference'
import {Text} from '#/components/Typography'
import {ErrorState} from '../ErrorState'
import {SectionRef} from './types'
interface LabelsSectionProps {
isLabelerLoading: boolean
@@ -33,6 +32,8 @@ interface LabelsSectionProps {
moderationOpts: ModerationOpts
scrollElRef: ListRef
headerHeight: number
isFocused: boolean
setScrollViewTag: (tag: number | null) => void
}
export const ProfileLabelsSection = React.forwardRef<
SectionRef,
@@ -45,6 +46,8 @@ export const ProfileLabelsSection = React.forwardRef<
moderationOpts,
scrollElRef,
headerHeight,
isFocused,
setScrollViewTag,
},
ref,
) {
@@ -64,6 +67,13 @@ export const ProfileLabelsSection = React.forwardRef<
scrollToTop: onScrollToTop,
}))
React.useEffect(() => {
if (isFocused && scrollElRef.current) {
const nativeTag = findNodeHandle(scrollElRef.current)
setScrollViewTag(nativeTag)
}
}, [isFocused, scrollElRef, setScrollViewTag])
return (
<CenteredView style={{flex: 1, minHeight}} sideBorders>
{isLabelerLoading ? (
@@ -197,9 +207,9 @@ export function ProfileLabelsSectionInner({
return (
<React.Fragment key={labelDef.identifier}>
{i !== 0 && <Divider />}
<ModerationLabelPref
<LabelerLabelPreference
disabled={isSubscribed ? undefined : true}
labelValueDefinition={labelDef}
labelDefinition={labelDef}
labelerDid={labelerInfo.creator.did}
/>
</React.Fragment>
@@ -0,0 +1,87 @@
import React from 'react'
import {StyleSheet} from 'react-native'
import {WebView, WebViewNavigation} from 'react-native-webview'
import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
import {SignupState} from '#/screens/Signup/state'
const ALLOWED_HOSTS = [
'bsky.social',
'bsky.app',
'staging.bsky.app',
'staging.bsky.dev',
'js.hcaptcha.com',
'newassets.hcaptcha.com',
'api2.hcaptcha.com',
]
export function CaptchaWebView({
url,
stateParam,
state,
onSuccess,
onError,
}: {
url: string
stateParam: string
state?: SignupState
onSuccess: (code: string) => void
onError: () => void
}) {
const redirectHost = React.useMemo(() => {
if (!state?.serviceUrl) return 'bsky.app'
return state?.serviceUrl &&
new URL(state?.serviceUrl).host === 'staging.bsky.dev'
? 'staging.bsky.app'
: 'bsky.app'
}, [state?.serviceUrl])
const wasSuccessful = React.useRef(false)
const onShouldStartLoadWithRequest = React.useCallback(
(event: ShouldStartLoadRequest) => {
const urlp = new URL(event.url)
return ALLOWED_HOSTS.includes(urlp.host)
},
[],
)
const onNavigationStateChange = React.useCallback(
(e: WebViewNavigation) => {
if (wasSuccessful.current) return
const urlp = new URL(e.url)
if (urlp.host !== redirectHost) return
const code = urlp.searchParams.get('code')
if (urlp.searchParams.get('state') !== stateParam || !code) {
onError()
return
}
wasSuccessful.current = true
onSuccess(code)
},
[redirectHost, stateParam, onSuccess, onError],
)
return (
<WebView
source={{uri: url}}
javaScriptEnabled
style={styles.webview}
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
onNavigationStateChange={onNavigationStateChange}
scrollEnabled={false}
/>
)
}
const styles = StyleSheet.create({
webview: {
flex: 1,
backgroundColor: 'transparent',
borderRadius: 10,
},
})
@@ -0,0 +1,61 @@
import React from 'react'
import {StyleSheet} from 'react-native'
// @ts-ignore web only, we will always redirect to the app on web (CORS)
const REDIRECT_HOST = new URL(window.location.href).host
export function CaptchaWebView({
url,
stateParam,
onSuccess,
onError,
}: {
url: string
stateParam: string
onSuccess: (code: string) => void
onError: () => void
}) {
const onLoad = React.useCallback(() => {
// @ts-ignore web
const frame: HTMLIFrameElement = document.getElementById(
'captcha-iframe',
) as HTMLIFrameElement
try {
// @ts-ignore web
const href = frame?.contentWindow?.location.href
if (!href) return
const urlp = new URL(href)
// This shouldn't happen with CORS protections, but for good measure
if (urlp.host !== REDIRECT_HOST) return
const code = urlp.searchParams.get('code')
if (urlp.searchParams.get('state') !== stateParam || !code) {
onError()
return
}
onSuccess(code)
} catch (e) {
// We don't need to handle this
}
}, [stateParam, onSuccess, onError])
return (
<iframe
src={url}
style={styles.iframe}
id="captcha-iframe"
onLoad={onLoad}
/>
)
}
const styles = StyleSheet.create({
iframe: {
flex: 1,
borderWidth: 0,
borderRadius: 10,
backgroundColor: 'transparent',
},
})
+80
View File
@@ -0,0 +1,80 @@
import React from 'react'
import {ActivityIndicator, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {nanoid} from 'nanoid/non-secure'
import {createFullHandle} from '#/lib/strings/handles'
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state'
import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView'
import {atoms as a, useTheme} from '#/alf'
import {FormError} from '#/components/forms/FormError'
const CAPTCHA_PATH = '/gate/signup'
export function StepCaptcha() {
const {_} = useLingui()
const theme = useTheme()
const {state, dispatch} = useSignupContext()
const submit = useSubmitSignup({state, dispatch})
const [completed, setCompleted] = React.useState(false)
const stateParam = React.useMemo(() => nanoid(15), [])
const url = React.useMemo(() => {
const newUrl = new URL(state.serviceUrl)
newUrl.pathname = CAPTCHA_PATH
newUrl.searchParams.set(
'handle',
createFullHandle(state.handle, state.userDomain),
)
newUrl.searchParams.set('state', stateParam)
newUrl.searchParams.set('colorScheme', theme.name)
return newUrl.href
}, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name])
const onSuccess = React.useCallback(
(code: string) => {
setCompleted(true)
submit(code)
},
[submit],
)
const onError = React.useCallback(() => {
dispatch({
type: 'setError',
value: _(msg`Error receiving captcha response.`),
})
}, [_, dispatch])
return (
<ScreenTransition>
<View style={[a.gap_lg]}>
<View
style={[
a.w_full,
a.pb_xl,
a.overflow_hidden,
{minHeight: 500},
completed && [a.align_center, a.justify_center],
]}>
{!completed ? (
<CaptchaWebView
url={url}
stateParam={stateParam}
state={state}
onSuccess={onSuccess}
onError={onError}
/>
) : (
<ActivityIndicator size="large" />
)}
</View>
<FormError error={state.error} />
</View>
</ScreenTransition>
)
}
+135
View File
@@ -0,0 +1,135 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {
createFullHandle,
IsValidHandle,
validateHandle,
} from '#/lib/strings/handles'
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
import {useSignupContext} from '#/screens/Signup/state'
import {atoms as a, useTheme} from '#/alf'
import * as TextField from '#/components/forms/TextField'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
export function StepHandle() {
const {_} = useLingui()
const t = useTheme()
const {state, dispatch} = useSignupContext()
const [validCheck, setValidCheck] = React.useState<IsValidHandle>({
handleChars: false,
hyphenStartOrEnd: false,
frontLength: false,
totalLength: true,
overall: false,
})
useFocusEffect(
React.useCallback(() => {
setValidCheck(validateHandle(state.handle, state.userDomain))
}, [state.handle, state.userDomain]),
)
const onHandleChange = React.useCallback(
(value: string) => {
if (state.error) {
dispatch({type: 'setError', value: ''})
}
dispatch({
type: 'setHandle',
value,
})
},
[dispatch, state.error],
)
return (
<ScreenTransition>
<View style={[a.gap_lg]}>
<View>
<TextField.Root>
<TextField.Icon icon={At} />
<TextField.Input
testID="handleInput"
onChangeText={onHandleChange}
label={_(msg`Input your user handle`)}
defaultValue={state.handle}
autoCapitalize="none"
autoCorrect={false}
autoFocus
autoComplete="off"
/>
</TextField.Root>
</View>
<Text style={[a.text_md]}>
<Trans>Your full handle will be</Trans>{' '}
<Text style={[a.text_md, a.font_bold]}>
@{createFullHandle(state.handle, state.userDomain)}
</Text>
</Text>
<View
style={[
a.w_full,
a.rounded_sm,
a.border,
a.p_md,
a.gap_sm,
t.atoms.border_contrast_low,
]}>
{state.error ? (
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
<IsValidIcon valid={false} />
<Text style={[a.text_md, a.flex_1]}>{state.error}</Text>
</View>
) : undefined}
{validCheck.hyphenStartOrEnd ? (
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
<IsValidIcon valid={validCheck.handleChars} />
<Text style={[a.text_md, a.flex_1]}>
<Trans>Only contains letters, numbers, and hyphens</Trans>
</Text>
</View>
) : (
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
<IsValidIcon valid={validCheck.hyphenStartOrEnd} />
<Text style={[a.text_md, a.flex_1]}>
<Trans>Doesn't begin or end with a hyphen</Trans>
</Text>
</View>
)}
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
<IsValidIcon
valid={validCheck.frontLength && validCheck.totalLength}
/>
{!validCheck.totalLength ? (
<Text style={[a.text_md, a.flex_1]}>
<Trans>No longer than 253 characters</Trans>
</Text>
) : (
<Text style={[a.text_md, a.flex_1]}>
<Trans>At least 3 characters</Trans>
</Text>
)}
</View>
</View>
</View>
</ScreenTransition>
)
}
function IsValidIcon({valid}: {valid: boolean}) {
const t = useTheme()
if (!valid) {
return <Times size="md" style={{color: t.palette.negative_500}} />
}
return <Check size="md" style={{color: t.palette.positive_700}} />
}
+97
View File
@@ -0,0 +1,97 @@
import React from 'react'
import {View} from 'react-native'
import {ComAtprotoServerDescribeServer} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
export const Policies = ({
serviceDescription,
needsGuardian,
under13,
}: {
serviceDescription: ComAtprotoServerDescribeServer.OutputSchema
needsGuardian: boolean
under13: boolean
}) => {
const t = useTheme()
const {_} = useLingui()
if (!serviceDescription) {
return <View />
}
const tos = validWebLink(serviceDescription.links?.termsOfService)
const pp = validWebLink(serviceDescription.links?.privacyPolicy)
if (!tos && !pp) {
return (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<CircleInfo size="md" fill={t.atoms.text_contrast_low.color} />
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>
This service has not provided terms of service or a privacy policy.
</Trans>
</Text>
</View>
)
}
const els = []
if (tos) {
els.push(
<InlineLinkText key="tos" to={tos}>
{_(msg`Terms of Service`)}
</InlineLinkText>,
)
}
if (pp) {
els.push(
<InlineLinkText key="pp" to={pp}>
{_(msg`Privacy Policy`)}
</InlineLinkText>,
)
}
if (els.length === 2) {
els.splice(
1,
0,
<Text key="and" style={[t.atoms.text_contrast_medium]}>
{' '}
and{' '}
</Text>,
)
}
return (
<View style={[a.gap_sm]}>
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>By creating an account you agree to the {els}.</Trans>
</Text>
{under13 ? (
<Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>You must be 13 years of age or older to sign up.</Trans>
</Text>
) : needsGuardian ? (
<Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
If you are not yet an adult according to the laws of your country,
your parent or legal guardian must read these Terms on your behalf.
</Trans>
</Text>
) : undefined}
</View>
)
}
function validWebLink(url?: string): string | undefined {
return url && (url.startsWith('http://') || url.startsWith('https://'))
? url
: undefined
}
+148
View File
@@ -0,0 +1,148 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
import {is13, is18, useSignupContext} from '#/screens/Signup/state'
import {Policies} from '#/screens/Signup/StepInfo/Policies'
import {atoms as a} from '#/alf'
import * as DateField from '#/components/forms/DateField'
import {FormError} from '#/components/forms/FormError'
import {HostingProvider} from '#/components/forms/HostingProvider'
import * as TextField from '#/components/forms/TextField'
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
import {Loader} from '#/components/Loader'
function sanitizeDate(date: Date): Date {
if (!date || date.toString() === 'Invalid Date') {
logger.error(`Create account: handled invalid date for birthDate`, {
hasDate: !!date,
})
return new Date()
}
return date
}
export function StepInfo() {
const {_} = useLingui()
const {state, dispatch} = useSignupContext()
return (
<ScreenTransition>
<View style={[a.gap_md]}>
<FormError error={state.error} />
<View>
<TextField.LabelText>
<Trans>Hosting provider</Trans>
</TextField.LabelText>
<HostingProvider
serviceUrl={state.serviceUrl}
onSelectServiceUrl={v =>
dispatch({type: 'setServiceUrl', value: v})
}
/>
</View>
{state.isLoading ? (
<View style={[a.align_center]}>
<Loader size="xl" />
</View>
) : state.serviceDescription ? (
<>
{state.serviceDescription.inviteCodeRequired && (
<View>
<TextField.LabelText>
<Trans>Invite code</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={Ticket} />
<TextField.Input
onChangeText={value => {
dispatch({
type: 'setInviteCode',
value: value.trim(),
})
}}
label={_(msg`Required for this provider`)}
defaultValue={state.inviteCode}
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
/>
</TextField.Root>
</View>
)}
<View>
<TextField.LabelText>
<Trans>Email</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={Envelope} />
<TextField.Input
testID="emailInput"
onChangeText={value => {
dispatch({
type: 'setEmail',
value: value.trim(),
})
}}
label={_(msg`Enter your email address`)}
defaultValue={state.email}
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
/>
</TextField.Root>
</View>
<View>
<TextField.LabelText>
<Trans>Password</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={Lock} />
<TextField.Input
testID="passwordInput"
onChangeText={value => {
dispatch({
type: 'setPassword',
value,
})
}}
label={_(msg`Choose your password`)}
defaultValue={state.password}
secureTextEntry
autoComplete="new-password"
/>
</TextField.Root>
</View>
<View>
<DateField.LabelText>
<Trans>Your birth date</Trans>
</DateField.LabelText>
<DateField.DateField
testID="date"
value={DateField.utils.toSimpleDateString(state.dateOfBirth)}
onChangeDate={date => {
dispatch({
type: 'setDateOfBirth',
value: sanitizeDate(new Date(date)),
})
}}
label={_(msg`Date of birth`)}
accessibilityHint={_(msg`Select your date of birth`)}
/>
</View>
<Policies
serviceDescription={state.serviceDescription}
needsGuardian={!is18(state.dateOfBirth)}
under13={!is13(state.dateOfBirth)}
/>
</>
) : undefined}
</View>
</ScreenTransition>
)
}
+234
View File
@@ -0,0 +1,234 @@
import React from 'react'
import {View} from 'react-native'
import {LayoutAnimationConfig} from 'react-native-reanimated'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {FEEDBACK_FORM_URL} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig'
import {createFullHandle} from '#/lib/strings/handles'
import {useServiceQuery} from '#/state/queries/service'
import {getAgent} from '#/state/session'
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
import {
initialState,
reducer,
SignupContext,
SignupStep,
useSubmitSignup,
} from '#/screens/Signup/state'
import {StepCaptcha} from '#/screens/Signup/StepCaptcha'
import {StepHandle} from '#/screens/Signup/StepHandle'
import {StepInfo} from '#/screens/Signup/StepInfo'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {AppLanguageDropdown} from '#/components/AppLanguageDropdown'
import {Button, ButtonText} from '#/components/Button'
import {Divider} from '#/components/Divider'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
export function Signup({onPressBack}: {onPressBack: () => void}) {
const {_} = useLingui()
const t = useTheme()
const {screen} = useAnalytics()
const [state, dispatch] = React.useReducer(reducer, initialState)
const submit = useSubmitSignup({state, dispatch})
const {gtMobile} = useBreakpoints()
const {
data: serviceInfo,
isFetching,
isError,
refetch,
} = useServiceQuery(state.serviceUrl)
React.useEffect(() => {
screen('CreateAccount')
}, [screen])
React.useEffect(() => {
if (isFetching) {
dispatch({type: 'setIsLoading', value: true})
} else if (!isFetching) {
dispatch({type: 'setIsLoading', value: false})
}
}, [isFetching])
React.useEffect(() => {
if (isError) {
dispatch({type: 'setServiceDescription', value: undefined})
dispatch({
type: 'setError',
value: _(
msg`Unable to contact your service. Please check your Internet connection.`,
),
})
} else if (serviceInfo) {
dispatch({type: 'setServiceDescription', value: serviceInfo})
dispatch({type: 'setError', value: ''})
}
}, [_, serviceInfo, isError])
const onNextPress = React.useCallback(async () => {
if (state.activeStep === SignupStep.HANDLE) {
try {
dispatch({type: 'setIsLoading', value: true})
const res = await getAgent().resolveHandle({
handle: createFullHandle(state.handle, state.userDomain),
})
if (res.data.did) {
dispatch({
type: 'setError',
value: _(msg`That handle is already taken.`),
})
return
}
} catch (e) {
// Don't have to handle
} finally {
dispatch({type: 'setIsLoading', value: false})
}
}
// phoneVerificationRequired is actually whether a captcha is required
if (
state.activeStep === SignupStep.HANDLE &&
!state.serviceDescription?.phoneVerificationRequired
) {
submit()
return
}
dispatch({type: 'next'})
logEvent('signup:nextPressed', {
activeStep: state.activeStep,
})
}, [
_,
state.activeStep,
state.handle,
state.serviceDescription?.phoneVerificationRequired,
state.userDomain,
submit,
])
const onBackPress = React.useCallback(() => {
if (state.activeStep !== SignupStep.INFO) {
dispatch({type: 'prev'})
} else {
onPressBack()
}
}, [onPressBack, state.activeStep])
return (
<SignupContext.Provider value={{state, dispatch}}>
<LoggedOutLayout
leadin=""
title={_(msg`Create Account`)}
description={_(msg`We're so excited to have you join us!`)}
scrollable>
<View testID="createAccount" style={a.flex_1}>
<View
style={[
a.flex_1,
a.px_xl,
a.pt_2xl,
!gtMobile && {paddingBottom: 100},
]}>
<View style={[a.gap_sm, a.pb_3xl]}>
<Text style={[a.font_semibold, t.atoms.text_contrast_medium]}>
<Trans>Step</Trans> {state.activeStep + 1} <Trans>of</Trans>{' '}
{state.serviceDescription &&
!state.serviceDescription.phoneVerificationRequired
? '2'
: '3'}
</Text>
<Text style={[a.text_3xl, a.font_bold]}>
{state.activeStep === SignupStep.INFO ? (
<Trans>Your account</Trans>
) : state.activeStep === SignupStep.HANDLE ? (
<Trans>Your user handle</Trans>
) : (
<Trans>Complete the challenge</Trans>
)}
</Text>
</View>
<View style={[a.pb_3xl]}>
<LayoutAnimationConfig skipEntering skipExiting>
{state.activeStep === SignupStep.INFO ? (
<StepInfo />
) : state.activeStep === SignupStep.HANDLE ? (
<StepHandle />
) : (
<StepCaptcha />
)}
</LayoutAnimationConfig>
</View>
<View style={[a.flex_row, a.justify_between, a.pb_lg]}>
<Button
label={_(msg`Go back to previous step`)}
variant="solid"
color="secondary"
size="medium"
onPress={onBackPress}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
{state.activeStep !== SignupStep.CAPTCHA && (
<>
{isError ? (
<Button
label={_(msg`Press to retry`)}
variant="solid"
color="primary"
size="medium"
disabled={state.isLoading}
onPress={() => refetch()}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
</Button>
) : (
<Button
testID="nextBtn"
label={_(msg`Continue to next step`)}
variant="solid"
color="primary"
size="medium"
disabled={!state.canNext || state.isLoading}
onPress={onNextPress}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
</Button>
)}
</>
)}
</View>
<Divider />
<View
style={[a.w_full, a.py_lg, a.flex_row, a.gap_lg, a.align_center]}>
<AppLanguageDropdown />
<Text style={[t.atoms.text, !gtMobile && a.text_md]}>
<Trans>Having trouble?</Trans>{' '}
<InlineLinkText
to={FEEDBACK_FORM_URL({email: state.email})}
style={[!gtMobile && a.text_md]}>
<Trans>Contact support</Trans>
</InlineLinkText>
</Text>
</View>
</View>
</View>
</LoggedOutLayout>
</SignupContext.Provider>
)
}
+320
View File
@@ -0,0 +1,320 @@
import React, {useCallback} from 'react'
import {LayoutAnimation} from 'react-native'
import {
ComAtprotoServerCreateAccount,
ComAtprotoServerDescribeServer,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as EmailValidator from 'email-validator'
import {DEFAULT_SERVICE, IS_PROD_SERVICE} from '#/lib/constants'
import {cleanError} from '#/lib/strings/errors'
import {createFullHandle, validateHandle} from '#/lib/strings/handles'
import {getAge} from '#/lib/strings/time'
import {logger} from '#/logger'
import {
DEFAULT_PROD_FEEDS,
usePreferencesSetBirthDateMutation,
useSetSaveFeedsMutation,
} from '#/state/queries/preferences'
import {useSessionApi} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
export enum SignupStep {
INFO,
HANDLE,
CAPTCHA,
}
export type SignupState = {
hasPrev: boolean
canNext: boolean
activeStep: SignupStep
serviceUrl: string
serviceDescription?: ServiceDescription
userDomain: string
dateOfBirth: Date
email: string
password: string
inviteCode: string
handle: string
error: string
isLoading: boolean
}
export type SignupAction =
| {type: 'prev'}
| {type: 'next'}
| {type: 'finish'}
| {type: 'setStep'; value: SignupStep}
| {type: 'setServiceUrl'; value: string}
| {type: 'setServiceDescription'; value: ServiceDescription | undefined}
| {type: 'setEmail'; value: string}
| {type: 'setPassword'; value: string}
| {type: 'setDateOfBirth'; value: Date}
| {type: 'setInviteCode'; value: string}
| {type: 'setHandle'; value: string}
| {type: 'setVerificationCode'; value: string}
| {type: 'setError'; value: string}
| {type: 'setCanNext'; value: boolean}
| {type: 'setIsLoading'; value: boolean}
export const initialState: SignupState = {
hasPrev: false,
canNext: false,
activeStep: SignupStep.INFO,
serviceUrl: DEFAULT_SERVICE,
serviceDescription: undefined,
userDomain: '',
dateOfBirth: DEFAULT_DATE,
email: '',
password: '',
handle: '',
inviteCode: '',
error: '',
isLoading: false,
}
export function is13(date: Date) {
return getAge(date) >= 13
}
export function is18(date: Date) {
return getAge(date) >= 18
}
export function reducer(s: SignupState, a: SignupAction): SignupState {
let next = {...s}
switch (a.type) {
case 'prev': {
if (s.activeStep !== SignupStep.INFO) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
next.activeStep--
next.error = ''
}
break
}
case 'next': {
if (s.activeStep !== SignupStep.CAPTCHA) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
next.activeStep++
next.error = ''
}
break
}
case 'setStep': {
next.activeStep = a.value
break
}
case 'setServiceUrl': {
next.serviceUrl = a.value
break
}
case 'setServiceDescription': {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
next.serviceDescription = a.value
next.userDomain = a.value?.availableUserDomains[0] ?? ''
next.isLoading = false
break
}
case 'setEmail': {
next.email = a.value
break
}
case 'setPassword': {
next.password = a.value
break
}
case 'setDateOfBirth': {
next.dateOfBirth = a.value
break
}
case 'setInviteCode': {
next.inviteCode = a.value
break
}
case 'setHandle': {
next.handle = a.value
break
}
case 'setCanNext': {
next.canNext = a.value
break
}
case 'setIsLoading': {
next.isLoading = a.value
break
}
case 'setError': {
next.error = a.value
break
}
}
next.hasPrev = next.activeStep !== SignupStep.INFO
switch (next.activeStep) {
case SignupStep.INFO: {
const isValidEmail = EmailValidator.validate(next.email)
next.canNext =
!!(next.email && next.password && next.dateOfBirth) &&
(!next.serviceDescription?.inviteCodeRequired || !!next.inviteCode) &&
is13(next.dateOfBirth) &&
isValidEmail
break
}
case SignupStep.HANDLE: {
next.canNext =
!!next.handle && validateHandle(next.handle, next.userDomain).overall
break
}
}
logger.debug('signup', next)
if (s.activeStep !== next.activeStep) {
logger.debug('signup: step changed', {activeStep: next.activeStep})
}
return next
}
interface IContext {
state: SignupState
dispatch: React.Dispatch<SignupAction>
}
export const SignupContext = React.createContext<IContext>({} as IContext)
export const useSignupContext = () => React.useContext(SignupContext)
export function useSubmitSignup({
state,
dispatch,
}: {
state: SignupState
dispatch: (action: SignupAction) => void
}) {
const {_} = useLingui()
const {createAccount} = useSessionApi()
const {mutateAsync: setBirthDate} = usePreferencesSetBirthDateMutation()
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
const onboardingDispatch = useOnboardingDispatch()
return useCallback(
async (verificationCode?: string) => {
if (!state.email) {
dispatch({type: 'setStep', value: SignupStep.INFO})
return dispatch({
type: 'setError',
value: _(msg`Please enter your email.`),
})
}
if (!EmailValidator.validate(state.email)) {
dispatch({type: 'setStep', value: SignupStep.INFO})
return dispatch({
type: 'setError',
value: _(msg`Your email appears to be invalid.`),
})
}
if (!state.password) {
dispatch({type: 'setStep', value: SignupStep.INFO})
return dispatch({
type: 'setError',
value: _(msg`Please choose your password.`),
})
}
if (!state.handle) {
dispatch({type: 'setStep', value: SignupStep.HANDLE})
return dispatch({
type: 'setError',
value: _(msg`Please choose your handle.`),
})
}
if (
state.serviceDescription?.phoneVerificationRequired &&
!verificationCode
) {
dispatch({type: 'setStep', value: SignupStep.CAPTCHA})
return dispatch({
type: 'setError',
value: _(msg`Please complete the verification captcha.`),
})
}
dispatch({type: 'setError', value: ''})
dispatch({type: 'setIsLoading', value: true})
try {
onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
await createAccount({
service: state.serviceUrl,
email: state.email,
handle: createFullHandle(state.handle, state.userDomain),
password: state.password,
inviteCode: state.inviteCode.trim(),
verificationCode: verificationCode,
})
await setBirthDate({birthDate: state.dateOfBirth})
if (IS_PROD_SERVICE(state.serviceUrl)) {
setSavedFeeds(DEFAULT_PROD_FEEDS)
}
} catch (e: any) {
onboardingDispatch({type: 'skip'}) // undo starting the onboard
let errMsg = e.toString()
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
dispatch({
type: 'setError',
value: _(
msg`Invite code not accepted. Check that you input it correctly and try again.`,
),
})
dispatch({type: 'setStep', value: SignupStep.INFO})
return
}
if ([400, 429].includes(e.status)) {
logger.warn('Failed to create account', {message: e})
} else {
logger.error(`Failed to create account (${e.status} status)`, {
message: e,
})
}
const error = cleanError(errMsg)
const isHandleError = error.toLowerCase().includes('handle')
dispatch({type: 'setIsLoading', value: false})
dispatch({type: 'setError', value: cleanError(errMsg)})
dispatch({type: 'setStep', value: isHandleError ? 2 : 1})
} finally {
dispatch({type: 'setIsLoading', value: false})
}
},
[
state.email,
state.password,
state.handle,
state.serviceDescription?.phoneVerificationRequired,
state.serviceUrl,
state.userDomain,
state.inviteCode,
state.dateOfBirth,
dispatch,
_,
onboardingDispatch,
createAccount,
setBirthDate,
setSavedFeeds,
],
)
}