diff options
Diffstat (limited to 'src/screens')
46 files changed, 4623 insertions, 241 deletions
diff --git a/src/screens/Deactivated.tsx b/src/screens/Deactivated.tsx index f4c201475..7e87973cb 100644 --- a/src/screens/Deactivated.tsx +++ b/src/screens/Deactivated.tsx @@ -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> diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx new file mode 100644 index 000000000..46452f087 --- /dev/null +++ b/src/screens/Hashtag.tsx @@ -0,0 +1,165 @@ +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 {enforceLen} from 'lib/strings/helpers' +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} /> +} + +const keyExtractor = (item: PostView, index: number) => { + return `${item.uri}-${index}` +} + +export default function HashtagScreen({ + route, +}: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) { + const {tag, author} = route.params + const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() + const initialNumToRender = useInitialNumToRender() + const [isPTR, setIsPTR] = React.useState(false) + + const fullTag = React.useMemo(() => { + return `#${decodeURIComponent(tag)}` + }, [tag]) + + const queryParam = React.useMemo(() => { + if (!author) return fullTag + return `${fullTag} from:${sanitizeHandle(author)}` + }, [fullTag, author]) + + const headerTitle = React.useMemo(() => { + return enforceLen(fullTag.toLowerCase(), 24, true, 'middle') + }, [fullTag]) + + const sanitizedAuthor = React.useMemo(() => { + if (!author) return + return sanitizeHandle(author) + }, [author]) + + const { + data, + isFetching, + isLoading, + isRefetching, + isError, + error, + refetch, + fetchNextPage, + hasNextPage, + } = useSearchPostsQuery({query: queryParam}) + + const posts = React.useMemo(() => { + return data?.pages.flatMap(page => page.posts) || [] + }, [data]) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + const onShare = React.useCallback(() => { + const url = new URL('https://bsky.app') + url.pathname = `/hashtag/${decodeURIComponent(tag)}` + if (author) { + url.searchParams.set('author', author) + } + shareUrl(url.toString()) + }, [tag, author]) + + const onRefresh = React.useCallback(async () => { + setIsPTR(true) + await refetch() + setIsPTR(false) + }, [refetch]) + + const onEndReached = React.useCallback(() => { + if (isFetching || !hasNextPage || error) return + fetchNextPage() + }, [isFetching, hasNextPage, error, fetchNextPage]) + + return ( + <> + <ViewHeader + title={headerTitle} + subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined} + canGoBack + renderButton={ + isNative + ? () => ( + <Pressable + accessibilityRole="button" + onPress={onShare} + hitSlop={HITSLOP_10}> + <ArrowOutOfBox_Stroke2_Corner0_Rounded + size="lg" + onPress={onShare} + /> + </Pressable> + ) + : 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> + data={posts} + renderItem={renderItem} + keyExtractor={keyExtractor} + refreshing={isPTR} + onRefresh={onRefresh} + onEndReached={onEndReached} + onEndReachedThreshold={4} + // @ts-ignore web only -prf + desktopFixedHeight + ListHeaderComponent={ + <ListHeaderDesktop + title={headerTitle} + subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined} + /> + } + ListFooterComponent={ + <ListFooter + isFetching={isFetching && !isRefetching} + isError={isError} + error={error?.name} + onRetry={fetchNextPage} + /> + } + initialNumToRender={initialNumToRender} + windowSize={11} + /> + )} + </> + ) +} diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx new file mode 100644 index 000000000..d0d4c784d --- /dev/null +++ b/src/screens/Login/ChooseAccountForm.tsx @@ -0,0 +1,188 @@ +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 {colors} from '#/lib/styles' +import {useProfileQuery} from '#/state/queries/profile' +import {SessionAccount, useSession, useSessionApi} from '#/state/session' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import * as Toast from '#/view/com/util/Toast' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import * as TextField from '#/components/forms/TextField' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron' +import {Text} from '#/components/Typography' +import {FormContainer} from './FormContainer' + +function AccountItem({ + account, + onSelect, + isCurrentAccount, +}: { + account: SessionAccount + onSelect: (account: SessionAccount) => void + isCurrentAccount: boolean +}) { + const t = useTheme() + const {_} = useLingui() + const {data: profile} = useProfileQuery({did: account.did}) + + const onPress = React.useCallback(() => { + onSelect(account) + }, [account, onSelect]) + + return ( + <Button + testID={`chooseAccountBtn-${account.handle}`} + key={account.did} + style={[a.flex_1]} + onPress={onPress} + label={ + isCurrentAccount + ? _(msg`Continue as ${account.handle} (currently signed in)`) + : _(msg`Sign in as ${account.handle}`) + }> + {({hovered, pressed}) => ( + <View + style={[ + a.flex_1, + a.flex_row, + a.align_center, + {height: 48}, + (hovered || pressed) && t.atoms.bg_contrast_25, + ]}> + <View style={a.p_md}> + <UserAvatar avatar={profile?.avatar} size={24} /> + </View> + <Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}> + <Text style={[a.font_bold]}> + {profile?.displayName || account.handle}{' '} + </Text> + <Text style={[t.atoms.text_contrast_medium]}>{account.handle}</Text> + </Text> + {isCurrentAccount ? ( + <Check size="sm" style={[{color: colors.green3}, a.mr_md]} /> + ) : ( + <Chevron size="sm" style={[t.atoms.text, a.mr_md]} /> + )} + </View> + )} + </Button> + ) +} +export const ChooseAccountForm = ({ + onSelectAccount, + onPressBack, +}: { + onSelectAccount: (account?: SessionAccount) => void + onPressBack: () => void +}) => { + const {track, screen} = useAnalytics() + const {_} = useLingui() + const t = useTheme() + const {accounts, 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" + title={<Trans>Select account</Trans>}> + <View> + <TextField.Label> + <Trans>Sign in as...</Trans> + </TextField.Label> + <View + style={[ + a.rounded_md, + a.overflow_hidden, + a.border, + t.atoms.border_contrast_low, + ]}> + {accounts.map(account => ( + <React.Fragment key={account.did}> + <AccountItem + account={account} + onSelect={onSelect} + isCurrentAccount={account.did === currentAccount?.did} + /> + <View style={[a.border_b, t.atoms.border_contrast_low]} /> + </React.Fragment> + ))} + <Button + testID="chooseNewAccountBtn" + style={[a.flex_1]} + onPress={() => onSelectAccount(undefined)} + label={_(msg`Login to account that is not listed`)}> + {({hovered, pressed}) => ( + <View + style={[ + a.flex_1, + a.flex_row, + a.align_center, + {height: 48}, + (hovered || pressed) && t.atoms.bg_contrast_25, + ]}> + <Text + style={[ + a.align_baseline, + a.flex_1, + a.flex_row, + a.py_sm, + {paddingLeft: 48}, + ]}> + <Trans>Other account</Trans> + </Text> + <Chevron size="sm" style={[t.atoms.text, a.mr_md]} /> + </View> + )} + </Button> + </View> + </View> + <View style={[a.flex_row]}> + <Button + label={_(msg`Back`)} + variant="solid" + color="secondary" + size="medium" + onPress={onPressBack}> + {_(msg`Back`)} + </Button> + <View style={[a.flex_1]} /> + </View> + </FormContainer> + ) +} diff --git a/src/screens/Login/ForgotPasswordForm.tsx b/src/screens/Login/ForgotPasswordForm.tsx new file mode 100644 index 000000000..580452e75 --- /dev/null +++ b/src/screens/Login/ForgotPasswordForm.tsx @@ -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" + title={<Trans>Reset password</Trans>}> + <View> + <TextField.Label> + <Trans>Hosting provider</Trans> + </TextField.Label> + <HostingProvider + serviceUrl={serviceUrl} + onSelectServiceUrl={setServiceUrl} + onOpenDialog={onPressSelectService} + /> + </View> + <View> + <TextField.Label> + <Trans>Email address</Trans> + </TextField.Label> + <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> + ) +} diff --git a/src/screens/Login/FormContainer.tsx b/src/screens/Login/FormContainer.tsx new file mode 100644 index 000000000..0144a8b5b --- /dev/null +++ b/src/screens/Login/FormContainer.tsx @@ -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, + title, + children, + style, +}: { + testID?: string + title?: 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]}> + {title && !gtMobile && ( + <Text style={[a.text_xl, a.font_bold, t.atoms.text_contrast_high]}> + {title} + </Text> + )} + {children} + </View> + ) +} diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx new file mode 100644 index 000000000..6bf215ee5 --- /dev/null +++ b/src/screens/Login/LoginForm.tsx @@ -0,0 +1,266 @@ +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" title={<Trans>Sign in</Trans>}> + <View> + <TextField.Label> + <Trans>Hosting provider</Trans> + </TextField.Label> + <HostingProvider + serviceUrl={serviceUrl} + onSelectServiceUrl={setServiceUrl} + onOpenDialog={onPressSelectService} + /> + </View> + <View> + <TextField.Label> + <Trans>Account</Trans> + </TextField.Label> + <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}> + {_(msg`Retry`)} + </Button> + ) : !serviceDescription ? ( + <> + <ActivityIndicator /> + <Text style={[t.atoms.text_contrast_high, a.pl_md]}> + <Trans>Connecting...</Trans> + </Text> + </> + ) : isReady ? ( + <Button + 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> + ) +} diff --git a/src/screens/Login/PasswordUpdatedForm.tsx b/src/screens/Login/PasswordUpdatedForm.tsx new file mode 100644 index 000000000..5407f3f1e --- /dev/null +++ b/src/screens/Login/PasswordUpdatedForm.tsx @@ -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> + ) +} diff --git a/src/screens/Login/ScreenTransition.tsx b/src/screens/Login/ScreenTransition.tsx new file mode 100644 index 000000000..ab0a22367 --- /dev/null +++ b/src/screens/Login/ScreenTransition.tsx @@ -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> + ) +} diff --git a/src/screens/Login/ScreenTransition.web.tsx b/src/screens/Login/ScreenTransition.web.tsx new file mode 100644 index 000000000..4583720aa --- /dev/null +++ b/src/screens/Login/ScreenTransition.web.tsx @@ -0,0 +1 @@ +export {Fragment as ScreenTransition} from 'react' diff --git a/src/screens/Login/SetNewPasswordForm.tsx b/src/screens/Login/SetNewPasswordForm.tsx new file mode 100644 index 000000000..e7b488655 --- /dev/null +++ b/src/screens/Login/SetNewPasswordForm.tsx @@ -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" + title={<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.Label>Reset code</TextField.Label> + <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.Label>New password</TextField.Label> + <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> + ) +} diff --git a/src/screens/Login/index.tsx b/src/screens/Login/index.tsx new file mode 100644 index 000000000..1fce63d29 --- /dev/null +++ b/src/screens/Login/index.tsx @@ -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> + ) +} diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx new file mode 100644 index 000000000..9d51a6197 --- /dev/null +++ b/src/screens/Moderation/index.tsx @@ -0,0 +1,554 @@ +import React from 'react' +import {View} from 'react-native' +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 {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 {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 * as LabelingService from '#/components/LabelingServiceCard' +import {InlineLink, 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() + return ( + <View style={[a.p_xl]}> + <Text + style={[ + a.text_md, + a.leading_normal, + a.pb_md, + t.atoms.text_contrast_medium, + ]}> + <Trans> + Hmmmm, it seems we're having trouble loading this data. See below for + more details. If this issue persists, please contact us. + </Trans> + </Text> + <View + style={[ + a.relative, + a.py_md, + a.px_lg, + a.rounded_md, + a.mb_2xl, + t.atoms.bg_contrast_25, + ]}> + <Text style={[a.text_md, a.leading_normal]}>{error}</Text> + </View> + </View> + ) +} + +export function ModerationScreen( + _props: NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>, +) { + const t = useTheme() + const {_} = useLingui() + const { + isLoading: isPreferencesLoading, + error: preferencesError, + data: preferences, + } = usePreferencesQuery() + const {gtMobile} = useBreakpoints() + const {height} = useSafeAreaFrame() + + const isLoading = isPreferencesLoading + const error = preferencesError + + return ( + <CenteredView + testID="moderationScreen" + style={[ + t.atoms.border_contrast_low, + t.atoms.bg, + {minHeight: height}, + ...(gtMobile ? [a.border_l, a.border_r] : []), + ]}> + <ViewHeader title={_(msg`Moderation`)} showOnDesktop /> + + {isLoading ? ( + <View style={[a.w_full, a.align_center, a.pt_2xl]}> + <Loader size="xl" fill={t.atoms.text.color} /> + </View> + ) : error || !preferences ? ( + <ErrorState + error={ + preferencesError?.toString() || + _(msg`Something went wrong, please try again.`) + } + /> + ) : ( + <ModerationScreenInner preferences={preferences} /> + )} + </CenteredView> + ) +} + +function SubItem({ + title, + icon: Icon, + style, +}: ViewStyleProp & { + title: string + icon: React.ComponentType<SVGIconProps> +}) { + const t = useTheme() + return ( + <View + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.justify_between, + a.p_lg, + a.gap_sm, + style, + ]}> + <View style={[a.flex_row, a.align_center, a.gap_md]}> + <Icon size="md" style={[t.atoms.text_contrast_medium]} /> + <Text style={[a.text_sm, a.font_bold]}>{title}</Text> + </View> + <ChevronRight + size="sm" + style={[t.atoms.text_contrast_low, a.self_end, {paddingBottom: 2}]} + /> + </View> + ) +} + +export function ModerationScreenInner({ + preferences, +}: { + preferences: UsePreferencesQueryResponse +}) { + const {_} = useLingui() + const t = useTheme() + const setMinimalShellMode = useSetMinimalShellMode() + const {screen} = useAnalytics() + const {gtMobile} = useBreakpoints() + const {mutedWordsDialogControl} = useGlobalDialogsControlContext() + const birthdateDialogControl = Dialog.useDialogControl() + const { + isLoading: isLabelersLoading, + data: labelers, + error: labelersError, + } = useMyLabelersQuery() + + useFocusEffect( + React.useCallback(() => { + screen('Moderation') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) + + const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} = + usePreferencesSetAdultContentMutation() + const adultContentEnabled = !!( + (optimisticAdultContent && optimisticAdultContent.enabled) || + (!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled) + ) + const ageNotSet = !preferences.userAge + const isUnderage = (preferences.userAge || 0) < 18 + + const onToggleAdultContentEnabled = React.useCallback( + async (selected: boolean) => { + try { + await setAdultContentPref({ + enabled: selected, + }) + } catch (e: any) { + logger.error(`Failed to set adult content pref`, { + message: e.message, + }) + } + }, + [setAdultContentPref], + ) + + return ( + <ScrollView + contentContainerStyle={[ + a.border_0, + a.pt_2xl, + a.px_lg, + gtMobile && a.px_2xl, + ]}> + <Text + style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}> + <Trans>Moderation tools</Trans> + </Text> + + <View + style={[ + a.w_full, + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + ]}> + <Button + testID="mutedWordsBtn" + label={_(msg`Open muted words and tags settings`)} + onPress={() => mutedWordsDialogControl.open()}> + {state => ( + <SubItem + title={_(msg`Muted words & tags`)} + icon={Filter} + style={[ + (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], + ]} + /> + )} + </Button> + <Divider /> + <Link testID="moderationlistsBtn" to="/moderation/modlists"> + {state => ( + <SubItem + title={_(msg`Moderation lists`)} + icon={Group} + style={[ + (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], + ]} + /> + )} + </Link> + <Divider /> + <Link testID="mutedAccountsBtn" to="/moderation/muted-accounts"> + {state => ( + <SubItem + title={_(msg`Muted accounts`)} + icon={Person} + style={[ + (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], + ]} + /> + )} + </Link> + <Divider /> + <Link testID="blockedAccountsBtn" to="/moderation/blocked-accounts"> + {state => ( + <SubItem + title={_(msg`Blocked accounts`)} + icon={CircleBanSign} + style={[ + (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], + ]} + /> + )} + </Link> + </View> + + <Text + style={[ + a.pt_2xl, + a.pb_md, + a.text_md, + a.font_bold, + t.atoms.text_contrast_high, + ]}> + <Trans>Content filters</Trans> + </Text> + + <View style={[a.gap_md]}> + {ageNotSet && ( + <> + <Button + label={_(msg`Confirm your birthdate`)} + size="small" + variant="solid" + color="secondary" + onPress={() => { + birthdateDialogControl.open() + }} + style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}> + <ButtonText> + <Trans>Confirm your age:</Trans> + </ButtonText> + <ButtonText> + <Trans>Set birthdate</Trans> + </ButtonText> + </Button> + + <BirthDateSettingsDialog control={birthdateDialogControl} /> + </> + )} + <View + style={[ + a.w_full, + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + ]}> + {!ageNotSet && !isUnderage && ( + <> + <View + style={[ + a.py_lg, + a.px_lg, + a.flex_row, + a.align_center, + a.justify_between, + ]}> + <Text style={[a.font_semibold, t.atoms.text_contrast_high]}> + <Trans>Enable adult content</Trans> + </Text> + <Toggle.Item + label={_(msg`Toggle to enable or disable adult content`)} + name="adultContent" + value={adultContentEnabled} + onChange={onToggleAdultContentEnabled}> + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + <Text style={[t.atoms.text_contrast_medium]}> + {adultContentEnabled ? ( + <Trans>Enabled</Trans> + ) : ( + <Trans>Disabled</Trans> + )} + </Text> + <Toggle.Switch /> + </View> + </Toggle.Item> + </View> + <Divider /> + </> + )} + {!isUnderage && adultContentEnabled && ( + <> + <GlobalLabelPreference labelDefinition={LABELS.porn} /> + <Divider /> + <GlobalLabelPreference labelDefinition={LABELS.sexual} /> + <Divider /> + <GlobalLabelPreference + labelDefinition={LABELS['graphic-media']} + /> + <Divider /> + </> + )} + <GlobalLabelPreference labelDefinition={LABELS.nudity} /> + </View> + </View> + + <Text + style={[ + a.text_md, + a.font_bold, + a.pt_2xl, + a.pb_md, + t.atoms.text_contrast_high, + ]}> + <Trans>Advanced</Trans> + </Text> + + {isLabelersLoading ? ( + <View style={[a.w_full, a.align_center, a.p_lg]}> + <Loader size="xl" /> + </View> + ) : labelersError || !labelers ? ( + <View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}> + <Text> + <Trans> + We were unable to load your configured labelers at this time. + </Trans> + </Text> + </View> + ) : ( + <View style={[a.rounded_sm, t.atoms.bg_contrast_25]}> + {labelers.map((labeler, i) => { + return ( + <React.Fragment key={labeler.creator.did}> + {i !== 0 && <Divider />} + <LabelingService.Link labeler={labeler}> + {state => ( + <LabelingService.Outer + style={[ + i === 0 && { + borderTopLeftRadius: a.rounded_sm.borderRadius, + borderTopRightRadius: a.rounded_sm.borderRadius, + }, + i === labelers.length - 1 && { + borderBottomLeftRadius: a.rounded_sm.borderRadius, + borderBottomRightRadius: a.rounded_sm.borderRadius, + }, + (state.hovered || state.pressed) && [ + t.atoms.bg_contrast_50, + ], + ]}> + <LabelingService.Avatar avatar={labeler.creator.avatar} /> + <LabelingService.Content> + <LabelingService.Title + value={getLabelingServiceTitle({ + displayName: labeler.creator.displayName, + handle: labeler.creator.handle, + })} + /> + <LabelingService.Description + value={labeler.creator.description} + handle={labeler.creator.handle} + /> + </LabelingService.Content> + </LabelingService.Outer> + )} + </LabelingService.Link> + </React.Fragment> + ) + })} + </View> + )} + + <Text + style={[ + a.text_md, + a.font_bold, + a.pt_2xl, + a.pb_md, + t.atoms.text_contrast_high, + ]}> + <Trans>Logged-out visibility</Trans> + </Text> + + <PwiOptOut /> + + <View style={{height: 200}} /> + </ScrollView> + ) +} + +function PwiOptOut() { + const t = useTheme() + const {_} = useLingui() + const {currentAccount} = useSession() + const {data: profile} = useProfileQuery({did: currentAccount?.did}) + const updateProfile = useProfileUpdateMutation() + + const isOptedOut = + profile?.labels?.some(l => l.val === '!no-unauthenticated') || false + const canToggle = profile && !updateProfile.isPending + + const onToggleOptOut = React.useCallback(() => { + if (!profile) { + return + } + let wasAdded = false + updateProfile.mutate({ + profile, + updates: existing => { + // create labels attr if needed + existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels) + ? existing.labels + : { + $type: 'com.atproto.label.defs#selfLabels', + values: [], + } + + // toggle the label + const hasLabel = existing.labels.values.some( + l => l.val === '!no-unauthenticated', + ) + if (hasLabel) { + wasAdded = false + existing.labels.values = existing.labels.values.filter( + l => l.val !== '!no-unauthenticated', + ) + } else { + wasAdded = true + existing.labels.values.push({val: '!no-unauthenticated'}) + } + + // delete if no longer needed + if (existing.labels.values.length === 0) { + delete existing.labels + } + return existing + }, + checkCommitted: res => { + const exists = !!res.data.labels?.some( + l => l.val === '!no-unauthenticated', + ) + return exists === wasAdded + }, + }) + }, [updateProfile, profile]) + + return ( + <View style={[a.pt_sm]}> + <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}> + <Toggle.Item + disabled={!canToggle} + value={isOptedOut} + onChange={onToggleOptOut} + name="logged_out_visibility" + style={a.flex_1} + label={_( + msg`Discourage apps from showing my account to logged-out users`, + )}> + <Toggle.Switch /> + <Toggle.Label style={[a.text_md, a.flex_1]}> + <Trans> + Discourage apps from showing my account to logged-out users + </Trans> + </Toggle.Label> + </Toggle.Item> + + {updateProfile.isPending && <Loader />} + </View> + + <View style={[a.pt_md, a.gap_md, {paddingLeft: 38}]}> + <Text style={[a.leading_snug, t.atoms.text_contrast_high]}> + <Trans> + Bluesky will not show your profile and posts to logged-out users. + Other apps may not honor this request. This does not make your + account private. + </Trans> + </Text> + <Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}> + <Trans> + Note: Bluesky is an open and public network. This setting only + limits the visibility of your content on the Bluesky app and + website, and other apps may not respect this setting. Your content + may still be shown to logged-out users by other apps and websites. + </Trans> + </Text> + + <InlineLink to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy"> + <Trans>Learn more about what is public on Bluesky.</Trans> + </InlineLink> + </View> + </View> + ) +} diff --git a/src/screens/Onboarding/IconCircle.tsx b/src/screens/Onboarding/IconCircle.tsx deleted file mode 100644 index a54c8b4e4..000000000 --- a/src/screens/Onboarding/IconCircle.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react' -import {View} from 'react-native' - -import { - useTheme, - atoms as a, - ViewStyleProp, - TextStyleProp, - flatten, -} from '#/alf' -import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' -import {Props} from '#/components/icons/common' - -export function IconCircle({ - icon: Icon, - size = 'xl', - style, - iconStyle, -}: ViewStyleProp & { - icon: typeof Growth - size?: Props['size'] - iconStyle?: TextStyleProp['style'] -}) { - const t = useTheme() - - return ( - <View - style={[ - a.justify_center, - a.align_center, - a.rounded_full, - { - width: 64, - height: 64, - backgroundColor: - t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950, - }, - flatten(style), - ]}> - <Icon - size={size} - style={[ - { - color: t.palette.primary_500, - }, - flatten(iconStyle), - ]} - /> - </View> - ) -} diff --git a/src/screens/Onboarding/Layout.tsx b/src/screens/Onboarding/Layout.tsx index d887c0820..6337cee09 100644 --- a/src/screens/Onboarding/Layout.tsx +++ b/src/screens/Onboarding/Layout.tsx @@ -17,7 +17,7 @@ import { flatten, TextStyleProp, } from '#/alf' -import {H2, P, leading} from '#/components/Typography' +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' @@ -209,16 +209,18 @@ export function Title({ style, }: React.PropsWithChildren<TextStyleProp>) { return ( - <H2 + <Text style={[ a.pb_sm, + a.text_4xl, + a.font_bold, { lineHeight: leading(a.text_4xl, a.leading_tight), }, flatten(style), ]}> {children} - </H2> + </Text> ) } diff --git a/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx b/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx index dec53d2ed..1123f2675 100644 --- a/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx +++ b/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx @@ -238,13 +238,14 @@ function FeedCardInner({feed}: {feed: FeedSourceInfo; config: FeedConfig}) { /> </View> - <View style={[a.pt_2xs, a.flex_grow]}> + <View style={[a.pt_2xs, a.flex_1, a.flex_grow]}> <Text style={[ a.text_md, a.font_bold, ctx.selected && styles.textSelected, - ]}> + ]} + numberOfLines={1}> {feed.displayName} </Text> <Text diff --git a/src/screens/Onboarding/StepAlgoFeeds/index.tsx b/src/screens/Onboarding/StepAlgoFeeds/index.tsx index 33e519207..35f525ef2 100644 --- a/src/screens/Onboarding/StepAlgoFeeds/index.tsx +++ b/src/screens/Onboarding/StepAlgoFeeds/index.tsx @@ -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, useTheme} from '#/alf' -import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' -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 {logEvent} from '#/lib/statsig/statsig' import { - Title, Description, OnboardingControls, + Title, } from '#/screens/Onboarding/Layout' +import {Context} from '#/screens/Onboarding/state' import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard' -import {IconCircle} from '#/screens/Onboarding/IconCircle' +import {atoms as a, tokens, useTheme} 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 {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 @@ -89,6 +89,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(() => { diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index 72d53658b..0c81d2d25 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -1,33 +1,33 @@ 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 {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' -import {atoms as a, 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, OnboardingControls, + Title, } from '#/screens/Onboarding/Layout' -import {IconCircle} from '#/screens/Onboarding/IconCircle' +import {Context} from '#/screens/Onboarding/state' import { bulkWriteFollows, sortPrimaryAlgorithmFeeds, } from '#/screens/Onboarding/util' +import {atoms as a, useTheme} 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() @@ -76,6 +76,7 @@ export function StepFinished() { onboardDispatch({type: 'finish'}) track('OnboardingV2:StepFinished:End') track('OnboardingV2:Complete') + logEvent('onboarding:finished:nextPressed', {}) }, [state, dispatch, onboardDispatch, setSaving, saveFeeds, track]) React.useEffect(() => { diff --git a/src/screens/Onboarding/StepFollowingFeed.tsx b/src/screens/Onboarding/StepFollowingFeed.tsx index 114e274b6..e886a0891 100644 --- a/src/screens/Onboarding/StepFollowingFeed.tsx +++ b/src/screens/Onboarding/StepFollowingFeed.tsx @@ -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} 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 {IconCircle} from '#/screens/Onboarding/IconCircle' +import { + Description, + OnboardingControls, + Title, +} from '#/screens/Onboarding/Layout' +import {Context} from '#/screens/Onboarding/state' +import {atoms as a} 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() @@ -46,6 +46,7 @@ export function StepFollowingFeed() { const onContinue = React.useCallback(() => { dispatch({type: 'next'}) track('OnboardingV2:StepFollowingFeed:End') + logEvent('onboarding:followingFeed:nextPressed', {}) }, [track, dispatch]) React.useEffect(() => { diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx index 4eaf0366e..8f34cced9 100644 --- a/src/screens/Onboarding/StepInterests/index.tsx +++ b/src/screens/Onboarding/StepInterests/index.tsx @@ -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 {useAnalytics} from '#/lib/analytics/analytics' +import {logEvent} from '#/lib/statsig/statsig' +import {capitalize} from '#/lib/strings/capitalize' 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 {capitalize} from '#/lib/strings/capitalize' - -import {Context, ApiResponseMap} from '#/screens/Onboarding/state' import { - Title, Description, OnboardingControls, + Title, } from '#/screens/Onboarding/Layout' +import {ApiResponseMap, Context} from '#/screens/Onboarding/state' import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton' -import {IconCircle} from '#/screens/Onboarding/IconCircle' +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) diff --git a/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx index b38b3df1e..9e59c1db6 100644 --- a/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx +++ b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx @@ -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() @@ -56,7 +56,9 @@ export function AdultContentEnabledPref({ try { mutate({ - enabled: !(variables?.enabled ?? preferences?.adultContentEnabled), + enabled: !( + variables?.enabled ?? preferences?.moderationPrefs.adultContentEnabled + ), }) } catch (e) { Toast.show( @@ -75,7 +77,10 @@ export function AdultContentEnabledPref({ <Toggle.Item name={_(msg`Enable adult content in your feeds`)} label={_(msg`Enable adult content in your feeds`)} - value={variables?.enabled ?? preferences?.adultContentEnabled} + value={ + variables?.enabled ?? + preferences?.moderationPrefs.adultContentEnabled + } onChange={onToggleAdultContent}> <View style={[ @@ -85,7 +90,9 @@ export function AdultContentEnabledPref({ a.align_center, a.py_md, ]}> - <Text style={[a.font_bold]}>Enable Adult Content</Text> + <Text style={[a.font_bold]}> + <Trans>Enable Adult Content</Trans> + </Text> <Toggle.Switch /> </View> </Toggle.Item> @@ -106,7 +113,9 @@ export function AdultContentEnabledPref({ )} <Prompt.Outer control={prompt}> - <Prompt.Title>Adult Content</Prompt.Title> + <Prompt.Title> + <Trans>Adult Content</Trans> + </Prompt.Title> <Prompt.Description> <Trans> Due to Apple policies, adult content can only be enabled on the web @@ -114,7 +123,7 @@ export function AdultContentEnabledPref({ </Trans> </Prompt.Description> <Prompt.Actions> - <Prompt.Action onPress={prompt.close}>OK</Prompt.Action> + <Prompt.Action onPress={() => prompt.close()} cta={_(msg`OK`)} /> </Prompt.Actions> </Prompt.Outer> </> diff --git a/src/screens/Onboarding/StepModeration/ModerationOption.tsx b/src/screens/Onboarding/StepModeration/ModerationOption.tsx index c61b520ba..ac02a874c 100644 --- a/src/screens/Onboarding/StepModeration/ModerationOption.tsx +++ b/src/screens/Onboarding/StepModeration/ModerationOption.tsx @@ -1,40 +1,51 @@ import React from 'react' import {View} from 'react-native' -import {LabelPreference} from '@atproto/api' +import {LabelPreference, InterpretedLabelValueDefinition} from '@atproto/api' import {useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' -import Animated, {Easing, Layout, FadeIn} from 'react-native-reanimated' +import {msg, Trans} from '@lingui/macro' import { - CONFIGURABLE_LABEL_GROUPS, - ConfigurableLabelGroup, 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' export function ModerationOption({ - labelGroup, - isMounted, + labelValueDefinition, + disabled, }: { - labelGroup: ConfigurableLabelGroup - isMounted: React.MutableRefObject<boolean> + labelValueDefinition: InterpretedLabelValueDefinition + disabled?: boolean }) { const {_} = useLingui() const t = useTheme() - const groupInfo = CONFIGURABLE_LABEL_GROUPS[labelGroup] const {data: preferences} = usePreferencesQuery() const {mutate, variables} = usePreferencesSetContentLabelMutation() + const label = labelValueDefinition.identifier const visibility = - variables?.visibility ?? preferences?.contentLabels?.[labelGroup] + variables?.visibility ?? preferences?.moderationPrefs.labels?.[label] + + const allLabelStrings = useGlobalLabelStrings() + const labelStrings = + labelValueDefinition.identifier in allLabelStrings + ? allLabelStrings[labelValueDefinition.identifier] + : { + name: labelValueDefinition.identifier, + description: `Labeled "${labelValueDefinition.identifier}"`, + } const onChange = React.useCallback( (vis: string[]) => { - mutate({labelGroup, visibility: vis[0] as LabelPreference}) + mutate({ + label, + visibility: vis[0] as LabelPreference, + labelerDid: undefined, + }) }, - [mutate, labelGroup], + [mutate, label], ) const labels = { @@ -44,7 +55,7 @@ export function ModerationOption({ } return ( - <Animated.View + <View style={[ a.flex_row, a.justify_between, @@ -52,33 +63,37 @@ export function ModerationOption({ a.py_xs, a.px_xs, a.align_center, - ]} - layout={Layout.easing(Easing.ease).duration(200)} - entering={isMounted.current ? FadeIn : undefined}> - <View style={[a.gap_xs, {width: '50%'}]}> - <Text style={[a.font_bold]}>{groupInfo.title}</Text> + ]}> + <View style={[a.gap_xs, a.flex_1]}> + <Text style={[a.font_bold]}>{labelStrings.name}</Text> <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}> - {groupInfo.subtitle} + {labelStrings.description} </Text> </View> - <View style={[a.justify_center, {minHeight: 35}]}> - <ToggleButton.Group - label={_( - msg`Configure content filtering setting for category: ${groupInfo.title.toLowerCase()}`, - )} - values={[visibility ?? 'hide']} - onChange={onChange}> - <ToggleButton.Button name="hide" label={labels.hide}> - {labels.hide} - </ToggleButton.Button> - <ToggleButton.Button name="warn" label={labels.warn}> - {labels.warn} - </ToggleButton.Button> - <ToggleButton.Button name="ignore" label={labels.show}> - {labels.show} - </ToggleButton.Button> - </ToggleButton.Group> + <View style={[a.justify_center, {minHeight: 40}]}> + {disabled ? ( + <Text style={[a.font_bold]}> + <Trans>Hide</Trans> + </Text> + ) : ( + <ToggleButton.Group + label={_( + msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`, + )} + values={[visibility ?? 'hide']} + onChange={onChange}> + <ToggleButton.Button name="ignore" label={labels.show}> + {labels.show} + </ToggleButton.Button> + <ToggleButton.Button name="warn" label={labels.warn}> + {labels.warn} + </ToggleButton.Button> + <ToggleButton.Button name="hide" label={labels.hide}> + {labels.hide} + </ToggleButton.Button> + </ToggleButton.Group> + )} </View> - </Animated.View> + </View> ) } diff --git a/src/screens/Onboarding/StepModeration/index.tsx b/src/screens/Onboarding/StepModeration/index.tsx index c831b6880..c5bdf5622 100644 --- a/src/screens/Onboarding/StepModeration/index.tsx +++ b/src/screens/Onboarding/StepModeration/index.tsx @@ -1,40 +1,27 @@ import React from 'react' import {View} from 'react-native' -import {useLingui} from '@lingui/react' +import {LABELS} from '@atproto/api' import {msg, Trans} from '@lingui/macro' -import Animated, {Easing, Layout} from 'react-native-reanimated' +import {useLingui} from '@lingui/react' -import {atoms as a} from '#/alf' -import { - configurableAdultLabelGroups, - configurableOtherLabelGroups, - usePreferencesSetAdultContentMutation, -} from 'state/queries/preferences' -import {Divider} from '#/components/Divider' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -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 {logEvent} from '#/lib/statsig/statsig' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {usePreferencesSetAdultContentMutation} from 'state/queries/preferences' 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 '#/screens/Onboarding/IconCircle' - -function AnimatedDivider() { - return ( - <Animated.View layout={Layout.easing(Easing.ease).duration(200)}> - <Divider /> - </Animated.View> - ) -} +import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref' +import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption' +import {atoms as a} from '#/alf' +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 {Loader} from '#/components/Loader' export function StepModeration() { const {_} = useLingui() @@ -52,12 +39,13 @@ export function StepModeration() { const adultContentEnabled = !!( (variables && variables.enabled) || - (!variables && preferences?.adultContentEnabled) + (!variables && preferences?.moderationPrefs.adultContentEnabled) ) const onContinue = React.useCallback(() => { dispatch({type: 'next'}) track('OnboardingV2:StepModeration:End') + logEvent('onboarding:moderation:nextPressed', {}) }, [track, dispatch]) React.useEffect(() => { @@ -86,22 +74,19 @@ export function StepModeration() { <AdultContentEnabledPref mutate={mutate} variables={variables} /> <View style={[a.gap_sm, a.w_full]}> - {adultContentEnabled && - configurableAdultLabelGroups.map((g, index) => ( - <React.Fragment key={index}> - {index === 0 && <AnimatedDivider />} - <ModerationOption labelGroup={g} isMounted={isMounted} /> - <AnimatedDivider /> - </React.Fragment> - ))} - - {configurableOtherLabelGroups.map((g, index) => ( - <React.Fragment key={index}> - {!adultContentEnabled && index === 0 && <AnimatedDivider />} - <ModerationOption labelGroup={g} isMounted={isMounted} /> - <AnimatedDivider /> - </React.Fragment> - ))} + <ModerationOption + labelValueDefinition={LABELS.porn} + disabled={!adultContentEnabled} + /> + <ModerationOption + labelValueDefinition={LABELS.sexual} + disabled={!adultContentEnabled} + /> + <ModerationOption + labelValueDefinition={LABELS['graphic-media']} + disabled={!adultContentEnabled} + /> + <ModerationOption labelValueDefinition={LABELS.nudity} /> </View> </> )} diff --git a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx index 067005892..7e4ea1f8b 100644 --- a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx +++ b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx @@ -88,7 +88,7 @@ export function SuggestedAccountCard({ <UserAvatar size={48} avatar={profile.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> </View> <View style={[a.flex_1]}> diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx index 3caa38d4f..2e6161362 100644 --- a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx +++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx @@ -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, OnboardingControls, + Title, } 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 {IconCircle} from '#/screens/Onboarding/IconCircle' +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, @@ -76,7 +76,7 @@ export function StepSuggestedAccounts() { return aggregateInterestItems( state.interestsStepResults.selectedInterests, state.interestsStepResults.apiResponse.suggestedAccountDids, - state.interestsStepResults.apiResponse.suggestedAccountDids.default, + state.interestsStepResults.apiResponse.suggestedAccountDids.default || [], ) }, [state.interestsStepResults]) const moderationOpts = useModerationOpts() @@ -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 diff --git a/src/screens/Onboarding/StepTopicalFeeds.tsx b/src/screens/Onboarding/StepTopicalFeeds.tsx index 3640b764d..26b1c243b 100644 --- a/src/screens/Onboarding/StepTopicalFeeds.tsx +++ b/src/screens/Onboarding/StepTopicalFeeds.tsx @@ -1,42 +1,48 @@ 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} 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 {IS_TEST_USER} from 'lib/constants' +import {useSession} from 'state/session' import { - Title, Description, OnboardingControls, + Title, } from '#/screens/Onboarding/Layout' +import {Context} from '#/screens/Onboarding/state' import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard' import {aggregateInterestItems} from '#/screens/Onboarding/util' -import {IconCircle} from '#/screens/Onboarding/IconCircle' +import {atoms as a} 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() const {track} = useAnalytics() + const {currentAccount} = useSession() const {state, dispatch, interestsDisplayNames} = React.useContext(Context) const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([]) const [saving, setSaving] = React.useState(false) const suggestedFeedUris = React.useMemo(() => { - if (!IS_PROD) return [] + if (IS_TEST_USER(currentAccount?.handle)) return [] return aggregateInterestItems( state.interestsStepResults.selectedInterests, state.interestsStepResults.apiResponse.suggestedFeedUris, - state.interestsStepResults.apiResponse.suggestedFeedUris.default, + state.interestsStepResults.apiResponse.suggestedFeedUris.default || [], ).slice(0, 10) - }, [state.interestsStepResults]) + }, [ + currentAccount?.handle, + state.interestsStepResults.apiResponse.suggestedFeedUris, + state.interestsStepResults.selectedInterests, + ]) const interestsText = React.useMemo(() => { const i = state.interestsStepResults.selectedInterests.map( @@ -56,6 +62,10 @@ export function StepTopicalFeeds() { selectedFeeds: selectedFeedUris, selectedFeedsLength: selectedFeedUris.length, }) + logEvent('onboarding:topicalFeeds:nextPressed', { + selectedFeeds: selectedFeedUris, + selectedFeedsLength: selectedFeedUris.length, + }) }, [selectedFeedUris, dispatch, track]) React.useEffect(() => { diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts index bd8205ca2..969edbdd2 100644 --- a/src/screens/Onboarding/state.ts +++ b/src/screens/Onboarding/state.ts @@ -232,7 +232,7 @@ export function reducer( }) if (s.activeStep !== state.activeStep) { - logger.info(`onboarding: step changed`, {activeStep: state.activeStep}) + logger.debug(`onboarding: step changed`, {activeStep: state.activeStep}) } return state diff --git a/src/screens/Profile/ErrorState.tsx b/src/screens/Profile/ErrorState.tsx new file mode 100644 index 000000000..2ec2cf592 --- /dev/null +++ b/src/screens/Profile/ErrorState.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import {View} from 'react-native' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {useTheme, atoms as a} from '#/alf' +import {Text} from '#/components/Typography' +import {Button, ButtonText} from '#/components/Button' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {NavigationProp} from '#/lib/routes/types' + +export function ErrorState({error}: {error: string}) { + const t = useTheme() + const {_} = useLingui() + const navigation = useNavigation<NavigationProp>() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + return ( + <View style={[a.px_xl]}> + <CircleInfo width={48} style={[t.atoms.text_contrast_low]} /> + + <Text style={[a.text_xl, a.font_bold, a.pb_md, a.pt_xl]}> + <Trans>Hmmmm, we couldn't load that moderation service.</Trans> + </Text> + <Text + style={[ + a.text_md, + a.leading_normal, + a.pb_md, + t.atoms.text_contrast_medium, + ]}> + <Trans> + This moderation service is unavailable. See below for more details. If + this issue persists, contact us. + </Trans> + </Text> + <View + style={[ + a.relative, + a.py_md, + a.px_lg, + a.rounded_md, + a.mb_2xl, + t.atoms.bg_contrast_25, + ]}> + <Text style={[a.text_md, a.leading_normal]}>{error}</Text> + </View> + + <View style={{flexDirection: 'row'}}> + <Button + size="small" + color="secondary" + variant="solid" + label={_(msg`Go Back`)} + accessibilityHint="Return to previous page" + onPress={onPressBack}> + <ButtonText> + <Trans>Go Back</Trans> + </ButtonText> + </Button> + </View> + </View> + ) +} diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx new file mode 100644 index 000000000..b6d88db71 --- /dev/null +++ b/src/screens/Profile/Header/DisplayName.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' +import {sanitizeHandle} from 'lib/strings/handles' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {Shadow} from '#/state/cache/types' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' + +export function ProfileHeaderDisplayName({ + profile, + moderation, +}: { + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> + moderation: ModerationDecision +}) { + const t = useTheme() + return ( + <View pointerEvents="none"> + <Text + testID="profileHeaderDisplayName" + style={[t.atoms.text, a.text_4xl, {fontWeight: '500'}]}> + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + )} + </Text> + </View> + ) +} diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx new file mode 100644 index 000000000..fd1cbe533 --- /dev/null +++ b/src/screens/Profile/Header/Handle.tsx @@ -0,0 +1,46 @@ +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 {atoms as a, useTheme, web} from '#/alf' +import {Text} from '#/components/Typography' + +export function ProfileHeaderHandle({ + profile, +}: { + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> +}) { + const t = useTheme() + const invalidHandle = isInvalidHandle(profile.handle) + const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy + return ( + <View style={[a.flex_row, a.gap_xs, a.align_center]} pointerEvents="none"> + {profile.viewer?.followedBy && !blockHide ? ( + <View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}> + <Text style={[t.atoms.text, a.text_sm]}> + <Trans>Follows you</Trans> + </Text> + </View> + ) : undefined} + <Text + style={[ + invalidHandle + ? [ + a.border, + a.text_xs, + a.px_sm, + a.py_xs, + a.rounded_xs, + {borderColor: t.palette.contrast_200}, + ] + : [a.text_md, t.atoms.text_contrast_medium], + web({wordBreak: 'break-all'}), + ]}> + {invalidHandle ? <Trans>âš Invalid Handle</Trans> : `@${profile.handle}`} + </Text> + </View> + ) +} diff --git a/src/screens/Profile/Header/Metrics.tsx b/src/screens/Profile/Header/Metrics.tsx new file mode 100644 index 000000000..d9a8a01a8 --- /dev/null +++ b/src/screens/Profile/Header/Metrics.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyActorDefs} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {Shadow} from '#/state/cache/types' +import {pluralize} from '#/lib/strings/helpers' +import {makeProfileLink} from 'lib/routes/links' +import {formatCount} from 'view/com/util/numeric/format' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import {InlineLink} from '#/components/Link' + +export function ProfileHeaderMetrics({ + profile, +}: { + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> +}) { + const t = useTheme() + const {_} = useLingui() + const following = formatCount(profile.followsCount || 0) + const followers = formatCount(profile.followersCount || 0) + const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') + + return ( + <View + style={[a.flex_row, a.gap_sm, a.align_center, a.pb_md]} + pointerEvents="box-none"> + <InlineLink + testID="profileHeaderFollowersButton" + style={[a.flex_row, t.atoms.text]} + to={makeProfileLink(profile, 'followers')} + label={`${followers} ${pluralizedFollowers}`}> + <Text style={[a.font_bold, a.text_md]}>{followers} </Text> + <Text style={[t.atoms.text_contrast_medium, a.text_md]}> + {pluralizedFollowers} + </Text> + </InlineLink> + <InlineLink + testID="profileHeaderFollowsButton" + style={[a.flex_row, t.atoms.text]} + to={makeProfileLink(profile, 'follows')} + label={_(msg`${following} following`)}> + <Trans> + <Text style={[a.font_bold, a.text_md]}>{following} </Text> + <Text style={[t.atoms.text_contrast_medium, a.text_md]}> + following + </Text> + </Trans> + </InlineLink> + <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]}> + {pluralize(profile.postsCount || 0, 'post')} + </Text> + </Text> + </View> + ) +} diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx new file mode 100644 index 000000000..a93cda134 --- /dev/null +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -0,0 +1,329 @@ +import React, {memo, useMemo} from 'react' +import {View} from 'react-native' +import { + AppBskyActorDefs, + AppBskyLabelerDefs, + moderateProfile, + ModerationOpts, + RichText as RichTextAPI, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {Haptics} from '#/lib/haptics' +import {isAppLabeler} from '#/lib/moderation' +import {pluralize} from '#/lib/strings/helpers' +import {logger} from '#/logger' +import {Shadow} from '#/state/cache/types' +import {useModalControls} from '#/state/modals' +import {useLabelerSubscriptionMutation} from '#/state/queries/labeler' +import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {useAnalytics} from 'lib/analytics/analytics' +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 {ProfileHeaderShell} from './Shell' + +interface Props { + profile: AppBskyActorDefs.ProfileViewDetailed + labeler: AppBskyLabelerDefs.LabelerViewDetailed + descriptionRT: RichTextAPI | null + moderationOpts: ModerationOpts + hideBackButton?: boolean + isPlaceholderProfile?: boolean +} + +let ProfileHeaderLabeler = ({ + profile: profileUnshadowed, + labeler, + descriptionRT, + moderationOpts, + hideBackButton = false, + isPlaceholderProfile, +}: Props): React.ReactNode => { + const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = + useProfileShadow(profileUnshadowed) + const t = useTheme() + const {_} = useLingui() + const {currentAccount, hasSession} = useSession() + const {openModal} = useModalControls() + const {track} = useAnalytics() + const cantSubscribePrompt = Prompt.usePromptControl() + const isSelf = currentAccount?.did === profile.did + + const moderation = useMemo( + () => moderateProfile(profile, moderationOpts), + [profile, moderationOpts], + ) + const {data: preferences} = usePreferencesQuery() + const {mutateAsync: toggleSubscription, variables} = + useLabelerSubscriptionMutation() + const isSubscribed = + variables?.subscribe ?? + preferences?.moderationPrefs.labelers.find(l => l.did === profile.did) + const canSubscribe = + isSubscribed || + (preferences ? preferences?.moderationPrefs.labelers.length < 9 : false) + const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation() + const {mutateAsync: unlikeMod, isPending: isUnlikePending} = + useUnlikeMutation() + const [likeUri, setLikeUri] = React.useState<string>( + labeler.viewer?.like || '', + ) + const [likeCount, setLikeCount] = React.useState(labeler.likeCount || 0) + + const onToggleLiked = React.useCallback(async () => { + if (!labeler) { + return + } + try { + Haptics.default() + + if (likeUri) { + await unlikeMod({uri: likeUri}) + track('CustomFeed:Unlike') + setLikeCount(c => c - 1) + setLikeUri('') + } else { + const res = await likeMod({uri: labeler.uri, cid: labeler.cid}) + track('CustomFeed:Like') + setLikeCount(c => c + 1) + setLikeUri(res.uri) + } + } catch (e: any) { + Toast.show( + _( + msg`There was an an issue contacting the server, please check your internet connection and try again.`, + ), + ) + logger.error(`Failed to toggle labeler like`, {message: e.message}) + } + }, [labeler, likeUri, likeMod, unlikeMod, track, _]) + + const onPressEditProfile = React.useCallback(() => { + track('ProfileHeader:EditProfileButtonClicked') + openModal({ + name: 'edit-profile', + profile, + }) + }, [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 isMe = React.useMemo( + () => currentAccount?.did === profile.did, + [currentAccount, profile], + ) + + return ( + <ProfileHeaderShell + profile={profile} + moderation={moderation} + hideBackButton={hideBackButton} + isPlaceholderProfile={isPlaceholderProfile}> + <View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none"> + <View + style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_lg]} + pointerEvents="box-none"> + {isMe ? ( + <Button + testID="profileHeaderEditProfileButton" + size="small" + color="secondary" + variant="solid" + onPress={onPressEditProfile} + label={_(msg`Edit profile`)} + style={a.rounded_full}> + <ButtonText> + <Trans>Edit Profile</Trans> + </ButtonText> + </Button> + ) : !isAppLabeler(profile.did) ? ( + <> + <Button + testID="toggleSubscribeBtn" + label={ + isSubscribed + ? _(msg`Unsubscribe from this labeler`) + : _(msg`Subscribe to this labeler`) + } + disabled={!hasSession} + onPress={onPressSubscribe}> + {state => ( + <View + style={[ + { + paddingVertical: 12, + backgroundColor: + isSubscribed || !canSubscribe + ? state.hovered || state.pressed + ? t.palette.contrast_50 + : t.palette.contrast_25 + : state.hovered || state.pressed + ? tokens.color.temp_purple_dark + : tokens.color.temp_purple, + }, + a.px_lg, + a.rounded_sm, + a.gap_sm, + ]}> + <Text + style={[ + { + color: canSubscribe + ? isSubscribed + ? t.palette.contrast_700 + : t.palette.white + : t.palette.contrast_400, + }, + a.font_bold, + a.text_center, + ]}> + {isSubscribed ? ( + <Trans>Unsubscribe</Trans> + ) : ( + <Trans>Subscribe to Labeler</Trans> + )} + </Text> + </View> + )} + </Button> + </> + ) : null} + <ProfileMenu profile={profile} /> + </View> + <View style={[a.flex_col, a.gap_xs, a.pb_md]}> + <ProfileHeaderDisplayName profile={profile} moderation={moderation} /> + <ProfileHeaderHandle profile={profile} /> + </View> + {!isPlaceholderProfile && ( + <> + {isSelf && <ProfileHeaderMetrics profile={profile} />} + {descriptionRT && !moderation.ui('profileView').blur ? ( + <View pointerEvents="auto"> + <RichText + testID="profileHeaderDescription" + style={[a.text_md]} + numberOfLines={15} + value={descriptionRT} + /> + </View> + ) : undefined} + {!isAppLabeler(profile.did) && ( + <View style={[a.flex_row, a.gap_xs, a.align_center, a.pt_lg]}> + <Button + testID="toggleLikeBtn" + size="small" + color="secondary" + variant="solid" + shape="round" + label={_(msg`Like this feed`)} + disabled={!hasSession || isLikePending || isUnlikePending} + onPress={onToggleLiked}> + {likeUri ? ( + <HeartFilled fill={t.palette.negative_400} /> + ) : ( + <Heart fill={t.atoms.text_contrast_medium.color} /> + )} + </Button> + + {typeof likeCount === 'number' && ( + <Link + to={{ + screen: 'ProfileLabelerLikedBy', + params: { + name: labeler.creator.handle || labeler.creator.did, + }, + }} + size="tiny" + label={_( + msg`Liked by ${likeCount} ${pluralize( + likeCount, + 'user', + )}`, + )}> + {({hovered, focused, pressed}) => ( + <Text + style={[ + a.font_bold, + a.text_sm, + t.atoms.text_contrast_medium, + (hovered || focused || pressed) && + t.atoms.text_contrast_high, + ]}> + <Trans> + Liked by {likeCount} {pluralize(likeCount, 'user')} + </Trans> + </Text> + )} + </Link> + )} + </View> + )} + </> + )} + </View> + <CantSubscribePrompt control={cantSubscribePrompt} /> + </ProfileHeaderShell> + ) +} +ProfileHeaderLabeler = memo(ProfileHeaderLabeler) +export {ProfileHeaderLabeler} + +function CantSubscribePrompt({ + control, +}: { + control: DialogOuterProps['control'] +}) { + const {_} = useLingui() + return ( + <Prompt.Outer control={control}> + <Prompt.Title>Unable to subscribe</Prompt.Title> + <Prompt.Description> + <Trans> + We're sorry! You can only subscribe to ten labelers, and you've + reached your limit of ten. + </Trans> + </Prompt.Description> + <Prompt.Actions> + <Prompt.Action onPress={control.close} cta={_(msg`OK`)} /> + </Prompt.Actions> + </Prompt.Outer> + ) +} diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx new file mode 100644 index 000000000..8b9038244 --- /dev/null +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -0,0 +1,286 @@ +import React, {memo, useMemo} from 'react' +import {View} from 'react-native' +import { + AppBskyActorDefs, + ModerationOpts, + moderateProfile, + 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 {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 {logger} from '#/logger' +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 {ProfileMenu} from '#/view/com/profile/ProfileMenu' +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' + +interface Props { + profile: AppBskyActorDefs.ProfileViewDetailed + descriptionRT: RichTextAPI | null + moderationOpts: ModerationOpts + hideBackButton?: boolean + isPlaceholderProfile?: boolean +} + +let ProfileHeaderStandard = ({ + profile: profileUnshadowed, + descriptionRT, + moderationOpts, + hideBackButton = false, + isPlaceholderProfile, +}: Props): React.ReactNode => { + const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = + useProfileShadow(profileUnshadowed) + const t = useTheme() + const {currentAccount, hasSession} = useSession() + const {_} = useLingui() + const {openModal} = useModalControls() + const {track} = useAnalytics() + const moderation = useMemo( + () => moderateProfile(profile, moderationOpts), + [profile, moderationOpts], + ) + const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( + profile, + 'ProfileHeader', + ) + const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) + const unblockPromptControl = Prompt.usePromptControl() + const requireAuth = useRequireAuth() + + const onPressEditProfile = React.useCallback(() => { + track('ProfileHeader:EditProfileButtonClicked') + openModal({ + name: 'edit-profile', + profile, + }) + }, [track, openModal, profile]) + + const onPressFollow = () => { + requireAuth(async () => { + try { + track('ProfileHeader:FollowButtonClicked') + await queueFollow() + Toast.show( + _( + msg`Following ${sanitizeDisplayName( + profile.displayName || profile.handle, + moderation.ui('displayName'), + )}`, + ), + ) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to follow', {message: String(e)}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } + }) + } + + const onPressUnfollow = () => { + requireAuth(async () => { + try { + track('ProfileHeader:UnfollowButtonClicked') + await queueUnfollow() + Toast.show( + _( + msg`No longer following ${sanitizeDisplayName( + profile.displayName || profile.handle, + moderation.ui('displayName'), + )}`, + ), + ) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to unfollow', {message: String(e)}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } + }) + } + + const unblockAccount = React.useCallback(async () => { + track('ProfileHeader:UnblockAccountButtonClicked') + try { + await queueUnblock() + Toast.show(_(msg`Account unblocked`)) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to unblock account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } + }, [_, queueUnblock, track]) + + const isMe = React.useMemo( + () => currentAccount?.did === profile.did, + [currentAccount, profile], + ) + + return ( + <ProfileHeaderShell + profile={profile} + moderation={moderation} + hideBackButton={hideBackButton} + isPlaceholderProfile={isPlaceholderProfile}> + <View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none"> + <View + style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_sm]} + pointerEvents="box-none"> + {isMe ? ( + <Button + testID="profileHeaderEditProfileButton" + size="small" + color="secondary" + variant="solid" + onPress={onPressEditProfile} + label={_(msg`Edit profile`)} + style={a.rounded_full}> + <ButtonText> + <Trans>Edit Profile</Trans> + </ButtonText> + </Button> + ) : profile.viewer?.blocking ? ( + profile.viewer?.blockingByList ? null : ( + <Button + testID="unblockBtn" + size="small" + color="secondary" + variant="solid" + label={_(msg`Unblock`)} + disabled={!hasSession} + onPress={() => unblockPromptControl.open()} + style={a.rounded_full}> + <ButtonText> + <Trans context="action">Unblock</Trans> + </ButtonText> + </Button> + ) + ) : !profile.viewer?.blockedBy ? ( + <> + {hasSession && ( + <Button + testID="suggestedFollowsBtn" + size="small" + color={showSuggestedFollows ? 'primary' : 'secondary'} + variant="solid" + shape="round" + onPress={() => setShowSuggestedFollows(!showSuggestedFollows)} + label={_(msg`Show follows similar to ${profile.handle}`)}> + <FontAwesomeIcon + icon="user-plus" + style={ + showSuggestedFollows + ? {color: t.palette.white} + : t.atoms.text + } + size={14} + /> + </Button> + )} + + <Button + testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'} + size="small" + color={profile.viewer?.following ? 'secondary' : 'primary'} + variant="solid" + label={ + profile.viewer?.following + ? _(msg`Unfollow ${profile.handle}`) + : _(msg`Follow ${profile.handle}`) + } + disabled={!hasSession} + onPress={ + profile.viewer?.following ? onPressUnfollow : onPressFollow + } + style={[a.rounded_full, a.gap_xs]}> + <ButtonIcon + position="left" + icon={profile.viewer?.following ? Check : Plus} + /> + <ButtonText> + {profile.viewer?.following ? ( + <Trans>Following</Trans> + ) : ( + <Trans>Follow</Trans> + )} + </ButtonText> + </Button> + </> + ) : null} + <ProfileMenu profile={profile} /> + </View> + <View style={[a.flex_col, a.gap_xs, a.pb_sm]}> + <ProfileHeaderDisplayName profile={profile} moderation={moderation} /> + <ProfileHeaderHandle profile={profile} /> + </View> + {!isPlaceholderProfile && ( + <> + <ProfileHeaderMetrics profile={profile} /> + {descriptionRT && !moderation.ui('profileView').blur ? ( + <View pointerEvents="auto"> + <RichText + testID="profileHeaderDescription" + style={[a.text_md]} + numberOfLines={15} + value={descriptionRT} + /> + </View> + ) : undefined} + </> + )} + </View> + {showSuggestedFollows && ( + <ProfileHeaderSuggestedFollows + actorDid={profile.did} + requestDismiss={() => { + if (showSuggestedFollows) { + setShowSuggestedFollows(false) + } else { + track('ProfileHeader:SuggestedFollowsOpened') + setShowSuggestedFollows(true) + } + }} + /> + )} + <Prompt.Basic + control={unblockPromptControl} + title={_(msg`Unblock Account?`)} + description={_( + msg`The account will be able to interact with you after unblocking.`, + )} + onConfirm={unblockAccount} + confirmButtonCta={ + profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) + } + confirmButtonColor="negative" + /> + </ProfileHeaderShell> + ) +} +ProfileHeaderStandard = memo(ProfileHeaderStandard) +export {ProfileHeaderStandard} diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx new file mode 100644 index 000000000..c470cb286 --- /dev/null +++ b/src/screens/Profile/Header/Shell.tsx @@ -0,0 +1,164 @@ +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 {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 {atoms as a, useTheme} from '#/alf' +import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' +import {BlurView} from 'view/com/util/BlurView' +import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {UserBanner} from 'view/com/util/UserBanner' +import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' + +interface Props { + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> + moderation: ModerationDecision + hideBackButton?: boolean + isPlaceholderProfile?: boolean +} + +let ProfileHeaderShell = ({ + children, + profile, + moderation, + hideBackButton = false, + isPlaceholderProfile, +}: React.PropsWithChildren<Props>): React.ReactNode => { + const t = useTheme() + const {currentAccount} = useSession() + const {_} = useLingui() + const {openLightbox} = useLightboxControls() + const navigation = useNavigation<NavigationProp>() + const {isDesktop} = useWebMediaQueries() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + const onPressAvi = React.useCallback(() => { + const modui = moderation.ui('avatar') + if (profile.avatar && !(modui.blur && modui.noOverride)) { + openLightbox(new ProfileImageLightbox(profile)) + } + }, [openLightbox, profile, moderation]) + + const isMe = React.useMemo( + () => currentAccount?.did === profile.did, + [currentAccount, profile], + ) + + return ( + <View style={t.atoms.bg} pointerEvents="box-none"> + <View pointerEvents="none"> + {isPlaceholderProfile ? ( + <LoadingPlaceholder + width="100%" + height={150} + style={{borderRadius: 0}} + /> + ) : ( + <UserBanner + type={profile.associated?.labeler ? 'labeler' : 'default'} + banner={profile.banner} + moderation={moderation.ui('banner')} + /> + )} + </View> + + {children} + + <View style={[a.px_lg, a.pb_sm]} pointerEvents="box-none"> + <ProfileHeaderAlerts moderation={moderation} /> + {isMe && ( + <LabelsOnMe details={{did: profile.did}} labels={profile.labels} /> + )} + </View> + + {!isDesktop && !hideBackButton && ( + <TouchableWithoutFeedback + testID="profileHeaderBackBtn" + onPress={onPressBack} + hitSlop={BACK_HITSLOP} + accessibilityRole="button" + accessibilityLabel={_(msg`Back`)} + accessibilityHint=""> + <View style={styles.backBtnWrapper}> + <BlurView style={styles.backBtn} blurType="dark"> + <FontAwesomeIcon size={18} icon="angle-left" color="white" /> + </BlurView> + </View> + </TouchableWithoutFeedback> + )} + <TouchableWithoutFeedback + testID="profileHeaderAviButton" + onPress={onPressAvi} + accessibilityRole="image" + accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)} + accessibilityHint=""> + <View + style={[ + t.atoms.bg, + {borderColor: t.atoms.bg.backgroundColor}, + styles.avi, + profile.associated?.labeler && styles.aviLabeler, + ]}> + <UserAvatar + type={profile.associated?.labeler ? 'labeler' : 'user'} + size={90} + avatar={profile.avatar} + moderation={moderation.ui('avatar')} + /> + </View> + </TouchableWithoutFeedback> + </View> + ) +} +ProfileHeaderShell = memo(ProfileHeaderShell) +export {ProfileHeaderShell} + +const styles = StyleSheet.create({ + backBtnWrapper: { + position: 'absolute', + top: 10, + left: 10, + width: 30, + height: 30, + overflow: 'hidden', + borderRadius: 15, + // @ts-ignore web only + cursor: 'pointer', + }, + backBtn: { + width: 30, + height: 30, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center', + }, + avi: { + position: 'absolute', + top: 110, + left: 10, + width: 94, + height: 94, + borderRadius: 47, + borderWidth: 2, + }, + aviLabeler: { + borderRadius: 10, + }, +}) diff --git a/src/screens/Profile/Header/index.tsx b/src/screens/Profile/Header/index.tsx new file mode 100644 index 000000000..1280dd8b1 --- /dev/null +++ b/src/screens/Profile/Header/index.tsx @@ -0,0 +1,78 @@ +import React, {memo} from 'react' +import {StyleSheet, View} from 'react-native' +import { + AppBskyActorDefs, + AppBskyLabelerDefs, + ModerationOpts, + RichText as RichTextAPI, +} from '@atproto/api' +import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' +import {usePalette} from 'lib/hooks/usePalette' + +import {ProfileHeaderStandard} from './ProfileHeaderStandard' +import {ProfileHeaderLabeler} from './ProfileHeaderLabeler' + +let ProfileHeaderLoading = (_props: {}): React.ReactNode => { + const pal = usePalette('default') + return ( + <View style={pal.view}> + <LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} /> + <View + style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> + <LoadingPlaceholder width={80} height={80} style={styles.br40} /> + </View> + <View style={styles.content}> + <View style={[styles.buttonsLine]}> + <LoadingPlaceholder width={167} height={31} style={styles.br50} /> + </View> + </View> + </View> + ) +} +ProfileHeaderLoading = memo(ProfileHeaderLoading) +export {ProfileHeaderLoading} + +interface Props { + profile: AppBskyActorDefs.ProfileViewDetailed + labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined + descriptionRT: RichTextAPI | null + moderationOpts: ModerationOpts + hideBackButton?: boolean + isPlaceholderProfile?: boolean +} + +let ProfileHeader = (props: Props): React.ReactNode => { + if (props.profile.associated?.labeler) { + if (!props.labeler) { + return <ProfileHeaderLoading /> + } + return <ProfileHeaderLabeler {...props} labeler={props.labeler} /> + } + return <ProfileHeaderStandard {...props} /> +} +ProfileHeader = memo(ProfileHeader) +export {ProfileHeader} + +const styles = StyleSheet.create({ + avi: { + position: 'absolute', + top: 110, + left: 10, + width: 84, + height: 84, + borderRadius: 42, + borderWidth: 2, + }, + content: { + paddingTop: 8, + paddingHorizontal: 14, + paddingBottom: 4, + }, + buttonsLine: { + flexDirection: 'row', + marginLeft: 'auto', + marginBottom: 12, + }, + br40: {borderRadius: 40}, + br50: {borderRadius: 50}, +}) diff --git a/src/screens/Profile/ProfileLabelerLikedBy.tsx b/src/screens/Profile/ProfileLabelerLikedBy.tsx new file mode 100644 index 000000000..1d2167520 --- /dev/null +++ b/src/screens/Profile/ProfileLabelerLikedBy.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types' +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, +}: NativeStackScreenProps<CommonNavigatorParams, 'ProfileLabelerLikedBy'>) { + const setMinimalShellMode = useSetMinimalShellMode() + const {name: handleOrDid} = route.params + const uri = makeRecordUri(handleOrDid, 'app.bsky.labeler.service', 'self') + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + return ( + <View + style={[ + a.mx_auto, + a.w_full, + a.h_full_vh, + gtMobile && [ + { + maxWidth: 600, + }, + ], + ]}> + <ViewHeader title={_(msg`Liked By`)} /> + <LikedByList uri={uri} /> + </View> + ) +} diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx new file mode 100644 index 000000000..0a5e2208d --- /dev/null +++ b/src/screens/Profile/Sections/Feed.tsx @@ -0,0 +1,88 @@ +import React from 'react' +import {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 {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 {SectionRef} from './types' + +interface FeedSectionProps { + feed: FeedDescriptor + headerHeight: number + isFocused: boolean + scrollElRef: ListRef + ignoreFilterFor?: string +} +export const ProfileFeedSection = React.forwardRef< + SectionRef, + FeedSectionProps +>(function FeedSectionImpl( + {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor}, + ref, +) { + const {_} = useLingui() + const queryClient = useQueryClient() + const [hasNew, setHasNew] = React.useState(false) + const [isScrolledDown, setIsScrolledDown] = React.useState(false) + + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({ + animated: isNative, + offset: -headerHeight, + }) + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderPostsEmpty = React.useCallback(() => { + return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} /> + }, [_]) + + return ( + <View> + <Feed + testID="postsFeed" + enabled={isFocused} + feed={feed} + scrollElRef={scrollElRef} + onHasNew={setHasNew} + onScrolledDownChange={setIsScrolledDown} + renderEmptyState={renderPostsEmpty} + headerOffset={headerHeight} + renderEndOfFeed={ProfileEndOfFeed} + ignoreFilterFor={ignoreFilterFor} + /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onScrollToTop} + label={_(msg`Load new posts`)} + showIndicator={hasNew} + /> + )} + </View> + ) +}) + +function ProfileEndOfFeed() { + const pal = usePalette('default') + + return ( + <View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}> + <Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}> + <Trans>End of feed</Trans> + </Text> + </View> + ) +} diff --git a/src/screens/Profile/Sections/Labels.tsx b/src/screens/Profile/Sections/Labels.tsx new file mode 100644 index 000000000..5ba8f00a5 --- /dev/null +++ b/src/screens/Profile/Sections/Labels.tsx @@ -0,0 +1,214 @@ +import React from 'react' +import {View} from 'react-native' +import {useSafeAreaFrame} from 'react-native-safe-area-context' +import { + AppBskyLabelerDefs, + InterpretedLabelValueDefinition, + interpretLabelValueDefinitions, + ModerationOpts, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' +import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation' +import {useScrollHandlers} from '#/lib/ScrollContext' +import {isNative} from '#/platform/detection' +import {ListRef} from '#/view/com/util/List' +import {CenteredView, ScrollView} from '#/view/com/util/Views' +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 + labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed | undefined + labelerError: Error | null + moderationOpts: ModerationOpts + scrollElRef: ListRef + headerHeight: number +} +export const ProfileLabelsSection = React.forwardRef< + SectionRef, + LabelsSectionProps +>(function LabelsSectionImpl( + { + isLabelerLoading, + labelerInfo, + labelerError, + moderationOpts, + scrollElRef, + headerHeight, + }, + ref, +) { + const {_} = useLingui() + const {height: minHeight} = useSafeAreaFrame() + + const onScrollToTop = React.useCallback(() => { + // @ts-ignore TODO fix this + scrollElRef.current?.scrollTo({ + animated: isNative, + x: 0, + y: -headerHeight, + }) + }, [scrollElRef, headerHeight]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + return ( + <CenteredView style={{flex: 1, minHeight}} sideBorders> + {isLabelerLoading ? ( + <View style={[a.w_full, a.align_center]}> + <Loader size="xl" /> + </View> + ) : labelerError || !labelerInfo ? ( + <ErrorState + error={ + labelerError?.toString() || + _(msg`Something went wrong, please try again.`) + } + /> + ) : ( + <ProfileLabelsSectionInner + moderationOpts={moderationOpts} + labelerInfo={labelerInfo} + scrollElRef={scrollElRef} + headerHeight={headerHeight} + /> + )} + </CenteredView> + ) +}) + +export function ProfileLabelsSectionInner({ + moderationOpts, + labelerInfo, + scrollElRef, + headerHeight, +}: { + moderationOpts: ModerationOpts + labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed + scrollElRef: ListRef + headerHeight: number +}) { + const t = useTheme() + const contextScrollHandlers = useScrollHandlers() + + const scrollHandler = useAnimatedScrollHandler({ + onBeginDrag(e, ctx) { + contextScrollHandlers.onBeginDrag?.(e, ctx) + }, + onEndDrag(e, ctx) { + contextScrollHandlers.onEndDrag?.(e, ctx) + }, + onScroll(e, ctx) { + contextScrollHandlers.onScroll?.(e, ctx) + }, + }) + + const {labelValues} = labelerInfo.policies + const isSubscribed = isLabelerSubscribed(labelerInfo, moderationOpts) + const labelDefs = React.useMemo(() => { + const customDefs = interpretLabelValueDefinitions(labelerInfo) + return labelValues + .map(val => lookupLabelValueDefinition(val, customDefs)) + .filter( + def => def && def?.configurable, + ) as InterpretedLabelValueDefinition[] + }, [labelerInfo, labelValues]) + + return ( + <ScrollView + // @ts-ignore TODO fix this + ref={scrollElRef} + scrollEventThrottle={1} + contentContainerStyle={{ + paddingTop: headerHeight, + borderWidth: 0, + }} + contentOffset={{x: 0, y: headerHeight * -1}} + onScroll={scrollHandler}> + <View style={[a.pt_xl, a.px_lg, a.border_t, t.atoms.border_contrast_low]}> + <View> + <Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}> + <Trans> + Labels are annotations on users and content. They can be used to + hide, warn, and categorize the network. + </Trans> + </Text> + {labelerInfo.creator.viewer?.blocking ? ( + <View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}> + <CircleInfo size="sm" fill={t.atoms.text_contrast_medium.color} /> + <Text + style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}> + <Trans> + Blocking does not prevent this labeler from placing labels on + your account. + </Trans> + </Text> + </View> + ) : null} + {labelValues.length === 0 ? ( + <Text + style={[ + a.pt_xl, + t.atoms.text_contrast_high, + a.leading_snug, + a.text_sm, + ]}> + <Trans> + This labeler hasn't declared what labels it publishes, and may + not be active. + </Trans> + </Text> + ) : !isSubscribed ? ( + <Text + style={[ + a.pt_xl, + t.atoms.text_contrast_high, + a.leading_snug, + a.text_sm, + ]}> + <Trans> + Subscribe to @{labelerInfo.creator.handle} to use these labels: + </Trans> + </Text> + ) : null} + </View> + {labelDefs.length > 0 && ( + <View + style={[ + a.mt_xl, + a.w_full, + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + ]}> + {labelDefs.map((labelDef, i) => { + return ( + <React.Fragment key={labelDef.identifier}> + {i !== 0 && <Divider />} + <LabelerLabelPreference + disabled={isSubscribed ? undefined : true} + labelDefinition={labelDef} + labelerDid={labelerInfo.creator.did} + /> + </React.Fragment> + ) + })} + </View> + )} + + <View style={{height: 400}} /> + </View> + </ScrollView> + ) +} diff --git a/src/screens/Profile/Sections/types.ts b/src/screens/Profile/Sections/types.ts new file mode 100644 index 000000000..a7f77d648 --- /dev/null +++ b/src/screens/Profile/Sections/types.ts @@ -0,0 +1,3 @@ +export interface SectionRef { + scrollToTop: () => void +} diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx new file mode 100644 index 000000000..50918c4ce --- /dev/null +++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx @@ -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, + }, +}) diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx new file mode 100644 index 000000000..7791a58dd --- /dev/null +++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx @@ -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', + }, +}) diff --git a/src/screens/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx new file mode 100644 index 000000000..2429b0c5e --- /dev/null +++ b/src/screens/Signup/StepCaptcha/index.tsx @@ -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> + ) +} diff --git a/src/screens/Signup/StepHandle.tsx b/src/screens/Signup/StepHandle.tsx new file mode 100644 index 000000000..44a33b833 --- /dev/null +++ b/src/screens/Signup/StepHandle.tsx @@ -0,0 +1,134 @@ +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 + 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}} /> +} diff --git a/src/screens/Signup/StepInfo/Policies.tsx b/src/screens/Signup/StepInfo/Policies.tsx new file mode 100644 index 000000000..4879ae7b3 --- /dev/null +++ b/src/screens/Signup/StepInfo/Policies.tsx @@ -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 {InlineLink} 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( + <InlineLink key="tos" to={tos}> + {_(msg`Terms of Service`)} + </InlineLink>, + ) + } + if (pp) { + els.push( + <InlineLink key="pp" to={pp}> + {_(msg`Privacy Policy`)} + </InlineLink>, + ) + } + 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 +} diff --git a/src/screens/Signup/StepInfo/index.tsx b/src/screens/Signup/StepInfo/index.tsx new file mode 100644 index 000000000..136592a0b --- /dev/null +++ b/src/screens/Signup/StepInfo/index.tsx @@ -0,0 +1,146 @@ +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.Label> + <Trans>Hosting provider</Trans> + </TextField.Label> + <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.Label> + <Trans>Invite code</Trans> + </TextField.Label> + <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.Label> + <Trans>Email</Trans> + </TextField.Label> + <TextField.Root> + <TextField.Icon icon={Envelope} /> + <TextField.Input + 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.Label> + <Trans>Password</Trans> + </TextField.Label> + <TextField.Root> + <TextField.Icon icon={Lock} /> + <TextField.Input + onChangeText={value => { + dispatch({ + type: 'setPassword', + value, + }) + }} + label={_(msg`Choose your password`)} + defaultValue={state.password} + secureTextEntry + autoComplete="new-password" + /> + </TextField.Root> + </View> + <View> + <DateField.Label> + <Trans>Your birth date</Trans> + </DateField.Label> + <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> + ) +} diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx new file mode 100644 index 000000000..a085fe44c --- /dev/null +++ b/src/screens/Signup/index.tsx @@ -0,0 +1,228 @@ +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 {Button, ButtonText} from '#/components/Button' +import {Divider} from '#/components/Divider' +import {InlineLink} 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 + 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]}> + <Text style={[t.atoms.text_contrast_medium]}> + <Trans>Having trouble?</Trans>{' '} + <InlineLink to={FEEDBACK_FORM_URL({email: state.email})}> + <Trans>Contact support</Trans> + </InlineLink> + </Text> + </View> + </View> + </View> + </LoggedOutLayout> + </SignupContext.Provider> + ) +} diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts new file mode 100644 index 000000000..86a144368 --- /dev/null +++ b/src/screens/Signup/state.ts @@ -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, + ], + ) +} |