diff options
Diffstat (limited to 'src/view/com')
-rw-r--r-- | src/view/com/Button.tsx | 204 | ||||
-rw-r--r-- | src/view/com/Typography.tsx | 104 | ||||
-rw-r--r-- | src/view/com/auth/create/CreateAccount.tsx | 26 | ||||
-rw-r--r-- | src/view/com/auth/create/Step1.tsx | 345 | ||||
-rw-r--r-- | src/view/com/auth/create/Step2.tsx | 263 | ||||
-rw-r--r-- | src/view/com/auth/create/Step3.tsx | 2 | ||||
-rw-r--r-- | src/view/com/auth/create/StepHeader.tsx | 37 | ||||
-rw-r--r-- | src/view/com/auth/create/state.ts | 107 | ||||
-rw-r--r-- | src/view/com/composer/Composer.tsx | 2 | ||||
-rw-r--r-- | src/view/com/composer/select-language/SuggestedLanguage.tsx | 101 | ||||
-rw-r--r-- | src/view/com/lists/ListCard.tsx | 18 | ||||
-rw-r--r-- | src/view/com/modals/DeleteAccount.tsx | 7 | ||||
-rw-r--r-- | src/view/com/modals/InAppBrowserConsent.tsx | 102 | ||||
-rw-r--r-- | src/view/com/modals/LinkWarning.tsx | 6 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 4 | ||||
-rw-r--r-- | src/view/com/pager/FeedsTabBarMobile.tsx | 23 | ||||
-rw-r--r-- | src/view/com/posts/DiscoverFallbackHeader.tsx | 43 | ||||
-rw-r--r-- | src/view/com/posts/Feed.tsx | 8 | ||||
-rw-r--r-- | src/view/com/util/Link.tsx | 12 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/index.tsx | 17 |
20 files changed, 789 insertions, 642 deletions
diff --git a/src/view/com/Button.tsx b/src/view/com/Button.tsx deleted file mode 100644 index d1f70d4ae..000000000 --- a/src/view/com/Button.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import React from 'react' -import {Pressable, Text, PressableProps, TextProps} from 'react-native' -import * as tokens from '#/alf/tokens' -import {atoms} from '#/alf' - -export type ButtonType = - | 'primary' - | 'secondary' - | 'tertiary' - | 'positive' - | 'negative' -export type ButtonSize = 'small' | 'large' - -export type VariantProps = { - type?: ButtonType - size?: ButtonSize -} -type ButtonState = { - pressed: boolean - hovered: boolean - focused: boolean -} -export type ButtonProps = Omit<PressableProps, 'children'> & - VariantProps & { - children: - | ((props: { - state: ButtonState - type?: ButtonType - size?: ButtonSize - }) => React.ReactNode) - | React.ReactNode - | string - } -export type ButtonTextProps = TextProps & VariantProps - -export function Button({children, style, type, size, ...rest}: ButtonProps) { - const {baseStyles, hoverStyles} = React.useMemo(() => { - const baseStyles = [] - const hoverStyles = [] - - switch (type) { - case 'primary': - baseStyles.push({ - backgroundColor: tokens.color.blue_500, - }) - break - case 'secondary': - baseStyles.push({ - backgroundColor: tokens.color.gray_200, - }) - hoverStyles.push({ - backgroundColor: tokens.color.gray_100, - }) - break - default: - } - - switch (size) { - case 'large': - baseStyles.push( - atoms.py_md, - atoms.px_xl, - atoms.rounded_md, - atoms.gap_sm, - ) - break - case 'small': - baseStyles.push( - atoms.py_sm, - atoms.px_md, - atoms.rounded_sm, - atoms.gap_xs, - ) - break - default: - } - - return { - baseStyles, - hoverStyles, - } - }, [type, size]) - - const [state, setState] = React.useState({ - pressed: false, - hovered: false, - focused: false, - }) - - const onPressIn = React.useCallback(() => { - setState(s => ({ - ...s, - pressed: true, - })) - }, [setState]) - const onPressOut = React.useCallback(() => { - setState(s => ({ - ...s, - pressed: false, - })) - }, [setState]) - const onHoverIn = React.useCallback(() => { - setState(s => ({ - ...s, - hovered: true, - })) - }, [setState]) - const onHoverOut = React.useCallback(() => { - setState(s => ({ - ...s, - hovered: false, - })) - }, [setState]) - const onFocus = React.useCallback(() => { - setState(s => ({ - ...s, - focused: true, - })) - }, [setState]) - const onBlur = React.useCallback(() => { - setState(s => ({ - ...s, - focused: false, - })) - }, [setState]) - - return ( - <Pressable - {...rest} - style={state => [ - atoms.flex_row, - atoms.align_center, - ...baseStyles, - ...(state.hovered ? hoverStyles : []), - typeof style === 'function' ? style(state) : style, - ]} - onPressIn={onPressIn} - onPressOut={onPressOut} - onHoverIn={onHoverIn} - onHoverOut={onHoverOut} - onFocus={onFocus} - onBlur={onBlur}> - {typeof children === 'string' ? ( - <ButtonText type={type} size={size}> - {children} - </ButtonText> - ) : typeof children === 'function' ? ( - children({state, type, size}) - ) : ( - children - )} - </Pressable> - ) -} - -export function ButtonText({ - children, - style, - type, - size, - ...rest -}: ButtonTextProps) { - const textStyles = React.useMemo(() => { - const base = [] - - switch (type) { - case 'primary': - base.push({color: tokens.color.white}) - break - case 'secondary': - base.push({ - color: tokens.color.gray_700, - }) - break - default: - } - - switch (size) { - case 'small': - base.push(atoms.text_sm, {paddingBottom: 1}) - break - case 'large': - base.push(atoms.text_md, {paddingBottom: 1}) - break - default: - } - - return base - }, [type, size]) - - return ( - <Text - {...rest} - style={[ - atoms.flex_1, - atoms.font_semibold, - atoms.text_center, - ...textStyles, - style, - ]}> - {children} - </Text> - ) -} diff --git a/src/view/com/Typography.tsx b/src/view/com/Typography.tsx deleted file mode 100644 index 6579c2e51..000000000 --- a/src/view/com/Typography.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React from 'react' -import {Text as RNText, TextProps} from 'react-native' -import {useTheme, atoms, web} from '#/alf' - -export function Text({style, ...rest}: TextProps) { - const t = useTheme() - return <RNText style={[atoms.text_sm, t.atoms.text, style]} {...rest} /> -} - -export function H1({style, ...rest}: TextProps) { - const t = useTheme() - const attr = - web({ - role: 'heading', - 'aria-level': 1, - }) || {} - return ( - <RNText - {...attr} - {...rest} - style={[atoms.text_xl, atoms.font_bold, t.atoms.text, style]} - /> - ) -} - -export function H2({style, ...rest}: TextProps) { - const t = useTheme() - const attr = - web({ - role: 'heading', - 'aria-level': 2, - }) || {} - return ( - <RNText - {...attr} - {...rest} - style={[atoms.text_lg, atoms.font_bold, t.atoms.text, style]} - /> - ) -} - -export function H3({style, ...rest}: TextProps) { - const t = useTheme() - const attr = - web({ - role: 'heading', - 'aria-level': 3, - }) || {} - return ( - <RNText - {...attr} - {...rest} - style={[atoms.text_md, atoms.font_bold, t.atoms.text, style]} - /> - ) -} - -export function H4({style, ...rest}: TextProps) { - const t = useTheme() - const attr = - web({ - role: 'heading', - 'aria-level': 4, - }) || {} - return ( - <RNText - {...attr} - {...rest} - style={[atoms.text_sm, atoms.font_bold, t.atoms.text, style]} - /> - ) -} - -export function H5({style, ...rest}: TextProps) { - const t = useTheme() - const attr = - web({ - role: 'heading', - 'aria-level': 5, - }) || {} - return ( - <RNText - {...attr} - {...rest} - style={[atoms.text_xs, atoms.font_bold, t.atoms.text, style]} - /> - ) -} - -export function H6({style, ...rest}: TextProps) { - const t = useTheme() - const attr = - web({ - role: 'heading', - 'aria-level': 6, - }) || {} - return ( - <RNText - {...attr} - {...rest} - style={[atoms.text_xxs, atoms.font_bold, t.atoms.text, style]} - /> - ) -} diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 74307a631..449afb0d3 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -22,12 +22,13 @@ import { useSetSaveFeedsMutation, DEFAULT_PROD_FEEDS, } from '#/state/queries/preferences' -import {IS_PROD} from '#/lib/constants' +import {FEEDBACK_FORM_URL, IS_PROD} from '#/lib/constants' import {Step1} from './Step1' import {Step2} from './Step2' import {Step3} from './Step3' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {TextLink} from '../../util/Link' export function CreateAccount({onPressBack}: {onPressBack: () => void}) { const {screen} = useAnalytics() @@ -117,7 +118,7 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) { return ( <LoggedOutLayout - leadin={`Step ${uiState.step}`} + leadin="" title={_(msg`Create Account`)} description={_(msg`We're so excited to have you join us!`)}> <ScrollView testID="createAccount" style={pal.view}> @@ -176,6 +177,27 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) { </> ) : undefined} </View> + + <View style={styles.stepContainer}> + <View + style={[ + s.flexRow, + s.alignCenter, + pal.viewLight, + {borderRadius: 8, paddingHorizontal: 14, paddingVertical: 12}, + ]}> + <Text type="md" style={pal.textLight}> + <Trans>Having trouble?</Trans>{' '} + </Text> + <TextLink + type="md" + style={pal.link} + text={_(msg`Contact support`)} + href={FEEDBACK_FORM_URL({email: uiState.email})} + /> + </View> + </View> + <View style={{height: isTabletOrDesktop ? 50 : 400}} /> </ScrollView> </LoggedOutLayout> diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index 0f8581c0b..2ce77cf53 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -1,25 +1,38 @@ import React from 'react' -import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' +import { + ActivityIndicator, + Keyboard, + StyleSheet, + TouchableWithoutFeedback, + View, +} from 'react-native' +import {CreateAccountState, CreateAccountDispatch, is18} from './state' import {Text} from 'view/com/util/text/Text' +import {DateInput} from 'view/com/util/forms/DateInput' import {StepHeader} from './StepHeader' -import {CreateAccountState, CreateAccountDispatch} from './state' -import {useTheme} from 'lib/ThemeContext' -import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' -import {HelpTip} from '../util/HelpTip' +import {usePalette} from 'lib/hooks/usePalette' import {TextInput} from '../util/TextInput' -import {Button} from 'view/com/util/forms/Button' +import {Button} from '../../util/forms/Button' +import {Policies} from './Policies' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' -import {msg, Trans} from '@lingui/macro' +import {isWeb} from 'platform/detection' +import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {logger} from '#/logger' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants' -import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' +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 +} -/** STEP 1: Your hosting provider - * @field Bluesky (default) - * @field Other (staging, local dev, your own PDS, etc.) - */ export function Step1({ uiState, uiDispatch, @@ -28,136 +41,175 @@ export function Step1({ uiDispatch: CreateAccountDispatch }) { const pal = usePalette('default') - const [isDefaultSelected, setIsDefaultSelected] = React.useState(true) const {_} = useLingui() + const {openModal} = useModalControls() - const onPressDefault = React.useCallback(() => { - setIsDefaultSelected(true) - uiDispatch({type: 'set-service-url', value: PROD_SERVICE}) - }, [setIsDefaultSelected, uiDispatch]) + const onPressSelectService = React.useCallback(() => { + openModal({ + name: 'server-input', + initialService: uiState.serviceUrl, + onSelect: (url: string) => + uiDispatch({type: 'set-service-url', value: url}), + }) + Keyboard.dismiss() + }, [uiDispatch, uiState.serviceUrl, openModal]) - const onPressOther = React.useCallback(() => { - setIsDefaultSelected(false) - uiDispatch({type: 'set-service-url', value: 'https://'}) - }, [setIsDefaultSelected, uiDispatch]) + const onPressWaitlist = React.useCallback(() => { + openModal({name: 'waitlist'}) + }, [openModal]) - const onChangeServiceUrl = React.useCallback( - (v: string) => { - uiDispatch({type: 'set-service-url', value: v}) - }, - [uiDispatch], - ) + const birthDate = React.useMemo(() => { + return sanitizeDate(uiState.birthDate) + }, [uiState.birthDate]) return ( <View> - <StepHeader step="1" title={_(msg`Your hosting provider`)} /> - <Text style={[pal.text, s.mb10]}> - <Trans>This is the service that keeps you online.</Trans> - </Text> - <Option - testID="blueskyServerBtn" - isSelected={isDefaultSelected} - label="Bluesky" - help=" (default)" - onPress={onPressDefault} - /> - <Option - testID="otherServerBtn" - isSelected={!isDefaultSelected} - label="Other" - onPress={onPressOther}> - <View style={styles.otherForm}> - <Text nativeID="addressProvider" style={[pal.text, s.mb5]}> - <Trans>Enter the address of your provider:</Trans> - </Text> - <TextInput - testID="customServerInput" - icon="globe" - placeholder={_(msg`Hosting provider address`)} - value={uiState.serviceUrl} - editable - onChange={onChangeServiceUrl} - accessibilityHint={_(msg`Input hosting provider address`)} - accessibilityLabel={_(msg`Hosting provider address`)} - accessibilityLabelledBy="addressProvider" - /> - {LOGIN_INCLUDE_DEV_SERVERS && ( - <View style={[s.flexRow, s.mt10]}> - <Button - testID="stagingServerBtn" - type="default" - style={s.mr5} - label={_(msg`Staging`)} - onPress={() => onChangeServiceUrl(STAGING_SERVICE)} - /> - <Button - testID="localDevServerBtn" - type="default" - label={_(msg`Dev Server`)} - onPress={() => onChangeServiceUrl(LOCAL_DEV_SERVICE)} + <StepHeader uiState={uiState} title={_(msg`Your account`)}> + <View> + <Button + testID="selectServiceButton" + type="default" + style={{ + aspectRatio: 1, + justifyContent: 'center', + alignItems: 'center', + }} + accessibilityLabel={_(msg`Select service`)} + accessibilityHint={_(msg`Sets server for the Bluesky client`)} + onPress={onPressSelectService}> + <FontAwesomeIcon icon="server" size={21} /> + </Button> + </View> + </StepHeader> + + {!uiState.serviceDescription ? ( + <ActivityIndicator /> + ) : ( + <> + {uiState.isInviteCodeRequired && ( + <View style={s.pb20}> + <Text type="md-medium" style={[pal.text, s.mb2]}> + <Trans>Invite code</Trans> + </Text> + <TextInput + testID="inviteCodeInput" + icon="ticket" + placeholder={_(msg`Required for this provider`)} + value={uiState.inviteCode} + editable + onChange={value => uiDispatch({type: 'set-invite-code', value})} + accessibilityLabel={_(msg`Invite code`)} + accessibilityHint={_(msg`Input invite code to proceed`)} + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + autoFocus={true} /> </View> )} - </View> - </Option> - {uiState.error ? ( - <ErrorMessage message={uiState.error} style={styles.error} /> - ) : ( - <HelpTip text={_(msg`You can change hosting providers at any time.`)} /> - )} - </View> - ) -} -function Option({ - children, - isSelected, - label, - help, - onPress, - testID, -}: React.PropsWithChildren<{ - isSelected: boolean - label: string - help?: string - onPress: () => void - testID?: string -}>) { - const theme = useTheme() - const pal = usePalette('default') - const {_} = useLingui() - const circleFillStyle = React.useMemo( - () => ({ - backgroundColor: theme.palette.primary.background, - }), - [theme], - ) - - return ( - <View style={[styles.option, pal.border]}> - <TouchableWithoutFeedback - onPress={onPress} - testID={testID} - accessibilityRole="button" - accessibilityLabel={label} - accessibilityHint={_(msg`Sets hosting provider to ${label}`)}> - <View style={styles.optionHeading}> - <View style={[styles.circle, pal.border]}> - {isSelected ? ( - <View style={[circleFillStyle, styles.circleFill]} /> - ) : undefined} - </View> - <Text type="xl" style={pal.text}> - {label} - {help ? ( - <Text type="xl" style={pal.textLight}> - {help} + {!uiState.inviteCode && uiState.isInviteCodeRequired ? ( + <View style={[s.flexRow, s.alignCenter]}> + <Text style={pal.text}> + <Trans>Don't have an invite code?</Trans>{' '} </Text> - ) : undefined} - </Text> - </View> - </TouchableWithoutFeedback> - {isSelected && children} + <TouchableWithoutFeedback + onPress={onPressWaitlist} + accessibilityLabel={_(msg`Join the waitlist.`)} + accessibilityHint=""> + <View style={styles.touchable}> + <Text style={pal.link}> + <Trans>Join the waitlist.</Trans> + </Text> + </View> + </TouchableWithoutFeedback> + </View> + ) : ( + <> + <View style={s.pb20}> + <Text + type="md-medium" + style={[pal.text, s.mb2]} + nativeID="email"> + <Trans>Email address</Trans> + </Text> + <TextInput + testID="emailInput" + icon="envelope" + placeholder={_(msg`Enter your email address`)} + value={uiState.email} + editable + onChange={value => uiDispatch({type: 'set-email', value})} + accessibilityLabel={_(msg`Email`)} + accessibilityHint={_(msg`Input email for Bluesky account`)} + accessibilityLabelledBy="email" + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + autoFocus={!uiState.isInviteCodeRequired} + /> + </View> + + <View style={s.pb20}> + <Text + type="md-medium" + style={[pal.text, s.mb2]} + nativeID="password"> + <Trans>Password</Trans> + </Text> + <TextInput + testID="passwordInput" + icon="lock" + placeholder={_(msg`Choose your password`)} + value={uiState.password} + editable + secureTextEntry + onChange={value => uiDispatch({type: 'set-password', value})} + accessibilityLabel={_(msg`Password`)} + accessibilityHint={_(msg`Set password`)} + accessibilityLabelledBy="password" + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + /> + </View> + + <View style={s.pb20}> + <Text + type="md-medium" + style={[pal.text, s.mb2]} + nativeID="birthDate"> + <Trans>Your birth date</Trans> + </Text> + <DateInput + handleAsUTC + testID="birthdayInput" + value={birthDate} + onChange={value => + uiDispatch({type: 'set-birth-date', value}) + } + buttonType="default-light" + buttonStyle={[pal.border, styles.dateInputButton]} + buttonLabelType="lg" + accessibilityLabel={_(msg`Birthday`)} + accessibilityHint={_(msg`Enter your birth date`)} + accessibilityLabelledBy="birthDate" + /> + </View> + + {uiState.serviceDescription && ( + <Policies + serviceDescription={uiState.serviceDescription} + needsGuardian={!is18(uiState)} + /> + )} + </> + )} + </> + )} + {uiState.error ? ( + <ErrorMessage message={uiState.error} style={styles.error} /> + ) : undefined} </View> ) } @@ -165,34 +217,15 @@ function Option({ const styles = StyleSheet.create({ error: { borderRadius: 6, + marginTop: 10, }, - - option: { + dateInputButton: { borderWidth: 1, borderRadius: 6, - marginBottom: 10, - }, - optionHeading: { - flexDirection: 'row', - alignItems: 'center', - padding: 10, + paddingVertical: 14, }, - circle: { - width: 26, - height: 26, - borderRadius: 15, - padding: 4, - borderWidth: 1, - marginRight: 10, - }, - circleFill: { - width: 16, - height: 16, - borderRadius: 10, - }, - - otherForm: { - paddingBottom: 10, - paddingHorizontal: 12, + // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832. + touchable: { + ...(isWeb && {cursor: 'pointer'}), }, }) diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index 53e1e02c9..f938bb9ce 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -1,39 +1,28 @@ import React from 'react' -import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' -import {CreateAccountState, CreateAccountDispatch, is18} from './state' +import { + ActivityIndicator, + StyleSheet, + TouchableWithoutFeedback, + View, +} from 'react-native' +import { + CreateAccountState, + CreateAccountDispatch, + requestVerificationCode, +} from './state' import {Text} from 'view/com/util/text/Text' -import {DateInput} from 'view/com/util/forms/DateInput' import {StepHeader} from './StepHeader' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {TextInput} from '../util/TextInput' -import {Policies} from './Policies' +import {Button} from '../../util/forms/Button' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {isWeb} from 'platform/detection' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' -import {logger} from '#/logger' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import parsePhoneNumber from 'libphonenumber-js' -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 -} - -/** STEP 2: Your account - * @field Invite code or waitlist - * @field Email address - * @field Email address - * @field Email address - * @field Password - * @field Birth date - * @readonly Terms of service & privacy policy - */ export function Step2({ uiState, uiDispatch, @@ -43,130 +32,155 @@ export function Step2({ }) { const pal = usePalette('default') const {_} = useLingui() - const {openModal} = useModalControls() + const {isMobile} = useWebMediaQueries() - const onPressWaitlist = React.useCallback(() => { - openModal({name: 'waitlist'}) - }, [openModal]) + const onPressRequest = React.useCallback(() => { + if ( + uiState.verificationPhone.length >= 9 && + parsePhoneNumber(uiState.verificationPhone, 'US') + ) { + requestVerificationCode({uiState, uiDispatch, _}) + } else { + uiDispatch({ + type: 'set-error', + value: _( + msg`There's something wrong with this number. Please include your country and/or area code!`, + ), + }) + } + }, [uiState, uiDispatch, _]) - const birthDate = React.useMemo(() => { - return sanitizeDate(uiState.birthDate) - }, [uiState.birthDate]) + const onPressRetry = React.useCallback(() => { + uiDispatch({type: 'set-has-requested-verification-code', value: false}) + }, [uiDispatch]) + + const phoneNumberFormatted = React.useMemo( + () => + uiState.hasRequestedVerificationCode + ? parsePhoneNumber( + uiState.verificationPhone, + 'US', + )?.formatInternational() + : '', + [uiState.hasRequestedVerificationCode, uiState.verificationPhone], + ) return ( <View> - <StepHeader step="2" title={_(msg`Your account`)} /> + <StepHeader uiState={uiState} title={_(msg`SMS verification`)} /> - {uiState.isInviteCodeRequired && ( - <View style={s.pb20}> - <Text type="md-medium" style={[pal.text, s.mb2]}> - <Trans>Invite code</Trans> - </Text> - <TextInput - testID="inviteCodeInput" - icon="ticket" - placeholder={_(msg`Required for this provider`)} - value={uiState.inviteCode} - editable - onChange={value => uiDispatch({type: 'set-invite-code', value})} - accessibilityLabel={_(msg`Invite code`)} - accessibilityHint={_(msg`Input invite code to proceed`)} - autoCapitalize="none" - autoComplete="off" - autoCorrect={false} - /> - </View> - )} - - {!uiState.inviteCode && uiState.isInviteCodeRequired ? ( - <Text style={[s.alignBaseline, pal.text]}> - <Trans>Don't have an invite code?</Trans>{' '} - <TouchableWithoutFeedback - onPress={onPressWaitlist} - accessibilityLabel={_(msg`Join the waitlist.`)} - accessibilityHint=""> - <View style={styles.touchable}> - <Text style={pal.link}> - <Trans>Join the waitlist.</Trans> - </Text> - </View> - </TouchableWithoutFeedback> - </Text> - ) : ( + {!uiState.hasRequestedVerificationCode ? ( <> <View style={s.pb20}> - <Text type="md-medium" style={[pal.text, s.mb2]} nativeID="email"> - <Trans>Email address</Trans> + <Text + type="md-medium" + style={[pal.text, s.mb2]} + nativeID="phoneNumber"> + <Trans>Phone number</Trans> </Text> <TextInput - testID="emailInput" - icon="envelope" - placeholder={_(msg`Enter your email address`)} - value={uiState.email} + testID="phoneInput" + icon="phone" + placeholder={_(msg`Enter your phone number`)} + value={uiState.verificationPhone} editable - onChange={value => uiDispatch({type: 'set-email', value})} + onChange={value => + uiDispatch({type: 'set-verification-phone', value}) + } accessibilityLabel={_(msg`Email`)} - accessibilityHint={_(msg`Input email for Bluesky waitlist`)} - accessibilityLabelledBy="email" + accessibilityHint={_( + msg`Input phone number for SMS verification`, + )} + accessibilityLabelledBy="phoneNumber" + keyboardType="phone-pad" autoCapitalize="none" - autoComplete="off" + autoComplete="tel" autoCorrect={false} + autoFocus={true} /> + <Text type="sm" style={[pal.textLight, s.mt5]}> + <Trans> + Please enter a phone number that can receive SMS text messages. + </Trans> + </Text> </View> + <View style={isMobile ? {} : {flexDirection: 'row'}}> + {uiState.isProcessing ? ( + <ActivityIndicator /> + ) : ( + <Button + testID="requestCodeBtn" + type="primary" + label={_(msg`Request code`)} + labelStyle={isMobile ? [s.flex1, s.textCenter, s.f17] : []} + style={ + isMobile ? {paddingVertical: 12, paddingHorizontal: 20} : {} + } + onPress={onPressRequest} + /> + )} + </View> + </> + ) : ( + <> <View style={s.pb20}> - <Text - type="md-medium" - style={[pal.text, s.mb2]} - nativeID="password"> - <Trans>Password</Trans> - </Text> + <View + style={[ + s.flexRow, + s.mb5, + s.alignCenter, + {justifyContent: 'space-between'}, + ]}> + <Text + type="md-medium" + style={pal.text} + nativeID="verificationCode"> + <Trans>Verification code</Trans>{' '} + </Text> + <TouchableWithoutFeedback + onPress={onPressRetry} + accessibilityLabel={_(msg`Retry.`)} + accessibilityHint=""> + <View style={styles.touchable}> + <Text + type="md-medium" + style={pal.link} + nativeID="verificationCode"> + <Trans>Retry</Trans> + </Text> + </View> + </TouchableWithoutFeedback> + </View> <TextInput - testID="passwordInput" - icon="lock" - placeholder={_(msg`Choose your password`)} - value={uiState.password} + testID="codeInput" + icon="hashtag" + placeholder={_(msg`XXXXXX`)} + value={uiState.verificationCode} editable - secureTextEntry - onChange={value => uiDispatch({type: 'set-password', value})} - accessibilityLabel={_(msg`Password`)} - accessibilityHint={_(msg`Set password`)} - accessibilityLabelledBy="password" + onChange={value => + uiDispatch({type: 'set-verification-code', value}) + } + accessibilityLabel={_(msg`Email`)} + accessibilityHint={_( + msg`Input the verification code we have texted to you`, + )} + accessibilityLabelledBy="verificationCode" + keyboardType="phone-pad" autoCapitalize="none" - autoComplete="off" + autoComplete="one-time-code" + textContentType="oneTimeCode" autoCorrect={false} + autoFocus={true} /> - </View> - - <View style={s.pb20}> - <Text - type="md-medium" - style={[pal.text, s.mb2]} - nativeID="birthDate"> - <Trans>Your birth date</Trans> + <Text type="sm" style={[pal.textLight, s.mt5]}> + <Trans>Please enter the verification code sent to</Trans>{' '} + {phoneNumberFormatted}. </Text> - <DateInput - handleAsUTC - testID="birthdayInput" - value={birthDate} - onChange={value => uiDispatch({type: 'set-birth-date', value})} - buttonType="default-light" - buttonStyle={[pal.border, styles.dateInputButton]} - buttonLabelType="lg" - accessibilityLabel={_(msg`Birthday`)} - accessibilityHint={_(msg`Enter your birth date`)} - accessibilityLabelledBy="birthDate" - /> </View> - - {uiState.serviceDescription && ( - <Policies - serviceDescription={uiState.serviceDescription} - needsGuardian={!is18(uiState)} - /> - )} </> )} + {uiState.error ? ( <ErrorMessage message={uiState.error} style={styles.error} /> ) : undefined} @@ -179,11 +193,6 @@ const styles = StyleSheet.create({ borderRadius: 6, marginTop: 10, }, - dateInputButton: { - borderWidth: 1, - borderRadius: 6, - paddingVertical: 14, - }, // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832. touchable: { ...(isWeb && {cursor: 'pointer'}), diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx index 2b2b9f7fe..bc7956da4 100644 --- a/src/view/com/auth/create/Step3.tsx +++ b/src/view/com/auth/create/Step3.tsx @@ -25,7 +25,7 @@ export function Step3({ const {_} = useLingui() return ( <View> - <StepHeader step="3" title={_(msg`Your user handle`)} /> + <StepHeader uiState={uiState} title={_(msg`Your user handle`)} /> <View style={s.pb10}> <TextInput testID="handleInput" diff --git a/src/view/com/auth/create/StepHeader.tsx b/src/view/com/auth/create/StepHeader.tsx index 41f912051..af6bf5478 100644 --- a/src/view/com/auth/create/StepHeader.tsx +++ b/src/view/com/auth/create/StepHeader.tsx @@ -3,27 +3,42 @@ import {StyleSheet, View} from 'react-native' import {Text} from 'view/com/util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {Trans} from '@lingui/macro' +import {CreateAccountState} from './state' -export function StepHeader({step, title}: {step: string; title: string}) { +export function StepHeader({ + uiState, + title, + children, +}: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) { const pal = usePalette('default') + const numSteps = uiState.isPhoneVerificationRequired ? 3 : 2 return ( <View style={styles.container}> - <Text type="lg" style={[pal.textLight]}> - {step === '3' ? ( - <Trans>Last step!</Trans> - ) : ( - <Trans>Step {step} of 3</Trans> - )} - </Text> - <Text style={[pal.text]} type="title-xl"> - {title} - </Text> + <View> + <Text type="lg" style={[pal.textLight]}> + {uiState.step === 3 ? ( + <Trans>Last step!</Trans> + ) : ( + <Trans> + Step {uiState.step} of {numSteps} + </Trans> + )} + </Text> + + <Text style={[pal.text]} type="title-xl"> + {title} + </Text> + </View> + {children} </View> ) } const styles = StyleSheet.create({ container: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', marginBottom: 20, }, }) diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts index 62a8495b3..7e0310bb3 100644 --- a/src/view/com/auth/create/state.ts +++ b/src/view/com/auth/create/state.ts @@ -2,6 +2,7 @@ import {useReducer} from 'react' import { ComAtprotoServerDescribeServer, ComAtprotoServerCreateAccount, + BskyAgent, } from '@atproto/api' import {I18nContext, useLingui} from '@lingui/react' import {msg} from '@lingui/macro' @@ -13,6 +14,7 @@ import {cleanError} from '#/lib/strings/errors' import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding' import {ApiContext as SessionApiContext} from '#/state/session' import {DEFAULT_SERVICE} from '#/lib/constants' +import parsePhoneNumber from 'libphonenumber-js' export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago @@ -27,6 +29,9 @@ export type CreateAccountAction = | {type: 'set-invite-code'; value: string} | {type: 'set-email'; value: string} | {type: 'set-password'; value: string} + | {type: 'set-verification-phone'; value: string} + | {type: 'set-verification-code'; value: string} + | {type: 'set-has-requested-verification-code'; value: boolean} | {type: 'set-handle'; value: string} | {type: 'set-birth-date'; value: Date} | {type: 'next'} @@ -43,6 +48,9 @@ export interface CreateAccountState { inviteCode: string email: string password: string + verificationPhone: string + verificationCode: string + hasRequestedVerificationCode: boolean handle: string birthDate: Date @@ -50,6 +58,7 @@ export interface CreateAccountState { canBack: boolean canNext: boolean isInviteCodeRequired: boolean + isPhoneVerificationRequired: boolean } export type CreateAccountDispatch = (action: CreateAccountAction) => void @@ -66,15 +75,51 @@ export function useCreateAccount() { inviteCode: '', email: '', password: '', + verificationPhone: '', + verificationCode: '', + hasRequestedVerificationCode: false, handle: '', birthDate: DEFAULT_DATE, canBack: false, canNext: false, isInviteCodeRequired: false, + isPhoneVerificationRequired: false, }) } +export async function requestVerificationCode({ + uiState, + uiDispatch, + _, +}: { + uiState: CreateAccountState + uiDispatch: CreateAccountDispatch + _: I18nContext['_'] +}) { + const phoneNumber = parsePhoneNumber(uiState.verificationPhone, 'US')?.number + if (!phoneNumber) { + return + } + uiDispatch({type: 'set-error', value: ''}) + uiDispatch({type: 'set-processing', value: true}) + uiDispatch({type: 'set-verification-phone', value: phoneNumber}) + try { + const agent = new BskyAgent({service: uiState.serviceUrl}) + await agent.com.atproto.temp.requestPhoneVerification({ + phoneNumber, + }) + uiDispatch({type: 'set-has-requested-verification-code', value: true}) + } catch (e: any) { + logger.error( + `Failed to request sms verification code (${e.status} status)`, + {error: e}, + ) + uiDispatch({type: 'set-error', value: cleanError(e.toString())}) + } + uiDispatch({type: 'set-processing', value: false}) +} + export async function submit({ createAccount, onboardingDispatch, @@ -89,26 +134,36 @@ export async function submit({ _: I18nContext['_'] }) { if (!uiState.email) { - uiDispatch({type: 'set-step', value: 2}) + uiDispatch({type: 'set-step', value: 1}) return uiDispatch({ type: 'set-error', value: _(msg`Please enter your email.`), }) } if (!EmailValidator.validate(uiState.email)) { - uiDispatch({type: 'set-step', value: 2}) + uiDispatch({type: 'set-step', value: 1}) return uiDispatch({ type: 'set-error', value: _(msg`Your email appears to be invalid.`), }) } if (!uiState.password) { - uiDispatch({type: 'set-step', value: 2}) + uiDispatch({type: 'set-step', value: 1}) return uiDispatch({ type: 'set-error', value: _(msg`Please choose your password.`), }) } + if ( + uiState.isPhoneVerificationRequired && + (!uiState.verificationPhone || !uiState.verificationCode) + ) { + uiDispatch({type: 'set-step', value: 2}) + return uiDispatch({ + type: 'set-error', + value: _(msg`Please enter the code you received by SMS.`), + }) + } if (!uiState.handle) { uiDispatch({type: 'set-step', value: 3}) return uiDispatch({ @@ -127,6 +182,8 @@ export async function submit({ handle: createFullHandle(uiState.handle, uiState.userDomain), password: uiState.password, inviteCode: uiState.inviteCode.trim(), + verificationPhone: uiState.verificationPhone.trim(), + verificationCode: uiState.verificationCode.trim(), }) } catch (e: any) { onboardingDispatch({type: 'skip'}) // undo starting the onboard @@ -135,6 +192,9 @@ export async function submit({ errMsg = _( msg`Invite code not accepted. Check that you input it correctly and try again.`, ) + uiDispatch({type: 'set-step', value: 1}) + } else if (e.error === 'InvalidPhoneVerification') { + uiDispatch({type: 'set-step', value: 2}) } if ([400, 429].includes(e.status)) { @@ -201,6 +261,19 @@ function createReducer({_}: {_: I18nContext['_']}) { case 'set-password': { return compute({...state, password: action.value}) } + case 'set-verification-phone': { + return compute({ + ...state, + verificationPhone: action.value, + hasRequestedVerificationCode: false, + }) + } + case 'set-verification-code': { + return compute({...state, verificationCode: action.value.trim()}) + } + case 'set-has-requested-verification-code': { + return compute({...state, hasRequestedVerificationCode: action.value}) + } case 'set-handle': { return compute({...state, handle: action.value}) } @@ -208,7 +281,7 @@ function createReducer({_}: {_: I18nContext['_']}) { return compute({...state, birthDate: action.value}) } case 'next': { - if (state.step === 2) { + if (state.step === 1) { if (!is13(state)) { return compute({ ...state, @@ -218,10 +291,18 @@ function createReducer({_}: {_: I18nContext['_']}) { }) } } - return compute({...state, error: '', step: state.step + 1}) + let increment = 1 + if (state.step === 1 && !state.isPhoneVerificationRequired) { + increment = 2 + } + return compute({...state, error: '', step: state.step + increment}) } case 'back': { - return compute({...state, error: '', step: state.step - 1}) + let decrement = 1 + if (state.step === 3 && !state.isPhoneVerificationRequired) { + decrement = 2 + } + return compute({...state, error: '', step: state.step - decrement}) } } } @@ -230,12 +311,16 @@ function createReducer({_}: {_: I18nContext['_']}) { function compute(state: CreateAccountState): CreateAccountState { let canNext = true if (state.step === 1) { - canNext = !!state.serviceDescription - } else if (state.step === 2) { canNext = + !!state.serviceDescription && (!state.isInviteCodeRequired || !!state.inviteCode) && !!state.email && !!state.password + } else if (state.step === 2) { + canNext = + !state.isPhoneVerificationRequired || + (!!state.verificationPhone && + isValidVerificationCode(state.verificationCode)) } else if (state.step === 3) { canNext = !!state.handle } @@ -244,5 +329,11 @@ function compute(state: CreateAccountState): CreateAccountState { canBack: state.step > 1, canNext, isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired, + isPhoneVerificationRequired: + !!state.serviceDescription?.phoneVerificationRequired, } } + +function isValidVerificationCode(str: string): boolean { + return /[0-9]{6}/.test(str) +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index e24fdcf3e..1c2e126c8 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -45,6 +45,7 @@ import {Gallery} from './photos/Gallery' import {MAX_GRAPHEME_LENGTH} from 'lib/constants' import {LabelsBtn} from './labels/LabelsBtn' import {SelectLangBtn} from './select-language/SelectLangBtn' +import {SuggestedLanguage} from './select-language/SuggestedLanguage' import {insertMentionAt} from 'lib/strings/mention-manip' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -454,6 +455,7 @@ export const ComposePost = observer(function ComposePost({ ))} </View> ) : null} + <SuggestedLanguage text={richtext.text} /> <View style={[pal.border, styles.bottomBar]}> {canSelectImages ? ( <> diff --git a/src/view/com/composer/select-language/SuggestedLanguage.tsx b/src/view/com/composer/select-language/SuggestedLanguage.tsx new file mode 100644 index 000000000..987d89d36 --- /dev/null +++ b/src/view/com/composer/select-language/SuggestedLanguage.tsx @@ -0,0 +1,101 @@ +import React, {useEffect, useState} from 'react' +import {StyleSheet, View} from 'react-native' +import lande from 'lande' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {Text} from '../../util/text/Text' +import {Button} from '../../util/forms/Button' +import {code3ToCode2Strict, codeToLanguageName} from '#/locale/helpers' +import { + toPostLanguages, + useLanguagePrefs, + useLanguagePrefsApi, +} from '#/state/preferences/languages' +import {usePalette} from '#/lib/hooks/usePalette' +import {s} from '#/lib/styles' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' + +// fallbacks for safari +const onIdle = globalThis.requestIdleCallback || (cb => setTimeout(cb, 1)) +const cancelIdle = globalThis.cancelIdleCallback || clearTimeout + +export function SuggestedLanguage({text}: {text: string}) { + const [suggestedLanguage, setSuggestedLanguage] = useState<string>() + const langPrefs = useLanguagePrefs() + const setLangPrefs = useLanguagePrefsApi() + const pal = usePalette('default') + const {_} = useLingui() + + useEffect(() => { + const textTrimmed = text.trim() + + // Don't run the language model on small posts, the results are likely + // to be inaccurate anyway. + if (textTrimmed.length < 40) { + setSuggestedLanguage(undefined) + return + } + + const idle = onIdle(() => { + // Only select languages that have a high confidence and convert to code2 + const result = lande(textTrimmed).filter( + ([lang, value]) => value >= 0.97 && code3ToCode2Strict(lang), + ) + + setSuggestedLanguage( + result.length > 0 ? code3ToCode2Strict(result[0][0]) : undefined, + ) + }) + + return () => cancelIdle(idle) + }, [text]) + + return suggestedLanguage && + !toPostLanguages(langPrefs.postLanguage).includes(suggestedLanguage) ? ( + <View style={[pal.border, styles.infoBar]}> + <FontAwesomeIcon + icon="language" + style={pal.text as FontAwesomeIconStyle} + size={24} + /> + <Text style={[pal.text, s.flex1]}> + <Trans> + Are you writing in{' '} + <Text type="sm-bold" style={pal.text}> + {codeToLanguageName(suggestedLanguage)} + </Text> + ? + </Trans> + </Text> + + <Button + type="default" + onPress={() => setLangPrefs.setPostLanguage(suggestedLanguage)} + accessibilityLabel={_( + msg`Change post language to ${codeToLanguageName(suggestedLanguage)}`, + )} + accessibilityHint=""> + <Text type="button" style={[pal.link, s.fw600]}> + <Trans>Yes</Trans> + </Text> + </Button> + </View> + ) : null +} + +const styles = StyleSheet.create({ + infoBar: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 16, + paddingVertical: 12, + marginHorizontal: 10, + marginBottom: 10, + }, +}) diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx index 28e98144a..5750faec1 100644 --- a/src/view/com/lists/ListCard.tsx +++ b/src/view/com/lists/ListCard.tsx @@ -94,15 +94,23 @@ export const ListCard = ({ </Trans> ))} </Text> - {!!list.viewer?.muted && ( - <View style={s.flexRow}> + <View style={s.flexRow}> + {list.viewer?.muted ? ( <View style={[s.mt5, pal.btn, styles.pill]}> <Text type="xs" style={pal.text}> - <Trans>Subscribed</Trans> + <Trans>Muted</Trans> </Text> </View> - </View> - )} + ) : null} + + {list.viewer?.blocked ? ( + <View style={[s.mt5, pal.btn, styles.pill]}> + <Text type="xs" style={pal.text}> + <Trans>Blocked</Trans> + </Text> + </View> + ) : null} + </View> </View> {renderButton ? ( <View style={styles.layoutButton}>{renderButton()}</View> diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index 0cfc098d4..945d7bc89 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -160,7 +160,7 @@ export function Component({}: {}) { {/* TODO: Update this label to be more concise */} <Text type="lg" - style={styles.description} + style={[pal.text, styles.description]} nativeID="confirmationCode"> <Trans> Check your inbox for an email with the confirmation code to @@ -180,7 +180,10 @@ export function Component({}: {}) { msg`Input confirmation code for account deletion`, )} /> - <Text type="lg" style={styles.description} nativeID="password"> + <Text + type="lg" + style={[pal.text, styles.description]} + nativeID="password"> <Trans>Please enter your password as well:</Trans> </Text> <TextInput diff --git a/src/view/com/modals/InAppBrowserConsent.tsx b/src/view/com/modals/InAppBrowserConsent.tsx new file mode 100644 index 000000000..86bb46ca8 --- /dev/null +++ b/src/view/com/modals/InAppBrowserConsent.tsx @@ -0,0 +1,102 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' + +import {s} from 'lib/styles' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {ScrollView} from './util' +import {usePalette} from 'lib/hooks/usePalette' + +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import { + useOpenLink, + useSetInAppBrowser, +} from '#/state/preferences/in-app-browser' + +export const snapPoints = [350] + +export function Component({href}: {href: string}) { + const pal = usePalette('default') + const {closeModal} = useModalControls() + const {_} = useLingui() + const setInAppBrowser = useSetInAppBrowser() + const openLink = useOpenLink() + + const onUseIAB = React.useCallback(() => { + setInAppBrowser(true) + closeModal() + openLink(href, true) + }, [closeModal, setInAppBrowser, href, openLink]) + + const onUseLinking = React.useCallback(() => { + setInAppBrowser(false) + closeModal() + openLink(href, false) + }, [closeModal, setInAppBrowser, href, openLink]) + + return ( + <ScrollView + testID="inAppBrowserConsentModal" + style={[s.flex1, pal.view, {paddingHorizontal: 20, paddingTop: 10}]}> + <Text style={[pal.text, styles.title]}> + <Trans>How should we open this link?</Trans> + </Text> + <Text style={pal.text}> + <Trans> + Your choice will be saved, but can be changed later in settings. + </Trans> + </Text> + <View style={[styles.btnContainer]}> + <Button + testID="confirmBtn" + type="inverted" + onPress={onUseIAB} + accessibilityLabel={_(msg`Use in-app browser`)} + accessibilityHint="" + label={_(msg`Use in-app browser`)} + labelContainerStyle={{justifyContent: 'center', padding: 8}} + labelStyle={[s.f18]} + /> + <Button + testID="confirmBtn" + type="inverted" + onPress={onUseLinking} + accessibilityLabel={_(msg`Use my default browser`)} + accessibilityHint="" + label={_(msg`Use my default browser`)} + labelContainerStyle={{justifyContent: 'center', padding: 8}} + labelStyle={[s.f18]} + /> + <Button + testID="cancelBtn" + type="default" + onPress={() => { + closeModal() + }} + accessibilityLabel={_(msg`Cancel`)} + accessibilityHint="" + label="Cancel" + labelContainerStyle={{justifyContent: 'center', padding: 8}} + labelStyle={[s.f18]} + /> + </View> + </ScrollView> + ) +} + +const styles = StyleSheet.create({ + title: { + textAlign: 'center', + fontWeight: 'bold', + fontSize: 24, + marginBottom: 12, + }, + btnContainer: { + marginTop: 20, + flexDirection: 'column', + justifyContent: 'center', + rowGap: 10, + }, +}) diff --git a/src/view/com/modals/LinkWarning.tsx b/src/view/com/modals/LinkWarning.tsx index 39e6cc3e6..81fdc7285 100644 --- a/src/view/com/modals/LinkWarning.tsx +++ b/src/view/com/modals/LinkWarning.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {Linking, SafeAreaView, StyleSheet, View} from 'react-native' +import {SafeAreaView, StyleSheet, View} from 'react-native' import {ScrollView} from './util' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' @@ -12,6 +12,7 @@ import {isPossiblyAUrl, splitApexDomain} from 'lib/strings/url-helpers' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useModalControls} from '#/state/modals' +import {useOpenLink} from '#/state/preferences/in-app-browser' export const snapPoints = ['50%'] @@ -21,10 +22,11 @@ export function Component({text, href}: {text: string; href: string}) { const {isMobile} = useWebMediaQueries() const {_} = useLingui() const potentiallyMisleading = isPossiblyAUrl(text) + const openLink = useOpenLink() const onPressVisit = () => { closeModal() - Linking.openURL(href) + openLink(href) } return ( diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index f9d211d07..7f814d971 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -39,6 +39,7 @@ import * as ChangeEmailModal from './ChangeEmail' import * as SwitchAccountModal from './SwitchAccount' import * as LinkWarningModal from './LinkWarning' import * as EmbedConsentModal from './EmbedConsent' +import * as InAppBrowserConsentModal from './InAppBrowserConsent' const DEFAULT_SNAPPOINTS = ['90%'] const HANDLE_HEIGHT = 24 @@ -180,6 +181,9 @@ export function ModalsContainer() { } else if (activeModal?.name === 'embed-consent') { snapPoints = EmbedConsentModal.snapPoints element = <EmbedConsentModal.Component {...activeModal} /> + } else if (activeModal?.name === 'in-app-browser-consent') { + snapPoints = InAppBrowserConsentModal.snapPoints + element = <InAppBrowserConsentModal.Component {...activeModal} /> } else { return null } diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index 2c5ba5dfb..9c562f67d 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -20,6 +20,11 @@ import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' import {Logo} from '#/view/icons/Logo' +import {IS_DEV} from '#/env' +import {atoms} from '#/alf' +import {Link as Link2} from '#/components/Link' +import {ColorPalette_Stroke2_Corner0_Rounded as ColorPalette} from '#/components/icons/ColorPalette' + export function FeedsTabBar( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { @@ -68,7 +73,7 @@ export function FeedsTabBar( headerHeight.value = e.nativeEvent.layout.height }}> <View style={[pal.view, styles.topBar]}> - <View style={[pal.view]}> + <View style={[pal.view, {width: 100}]}> <TouchableOpacity testID="viewHeaderDrawerBtn" onPress={onPressAvi} @@ -88,7 +93,21 @@ export function FeedsTabBar( <View> <Logo width={30} /> </View> - <View style={[pal.view, {width: 18}]}> + <View + style={[ + atoms.flex_row, + atoms.justify_end, + atoms.align_center, + atoms.gap_md, + pal.view, + {width: 100}, + ]}> + {IS_DEV && ( + <Link2 to="/sys/debug"> + <ColorPalette size="md" /> + </Link2> + )} + {hasSession && ( <Link testID="viewHeaderHomeFeedPrefsBtn" diff --git a/src/view/com/posts/DiscoverFallbackHeader.tsx b/src/view/com/posts/DiscoverFallbackHeader.tsx new file mode 100644 index 000000000..ffde89997 --- /dev/null +++ b/src/view/com/posts/DiscoverFallbackHeader.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import {View} from 'react-native' +import {Trans} from '@lingui/macro' +import {Text} from '../util/text/Text' +import {usePalette} from '#/lib/hooks/usePalette' +import {TextLink} from '../util/Link' +import {InfoCircleIcon} from '#/lib/icons' + +export function DiscoverFallbackHeader() { + const pal = usePalette('default') + return ( + <View + style={[ + { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 12, + borderTopWidth: 1, + }, + pal.border, + pal.viewLight, + ]}> + <View style={{width: 68, paddingLeft: 12}}> + <InfoCircleIcon size={36} style={pal.textLight} strokeWidth={1.5} /> + </View> + <View style={{flex: 1}}> + <Text type="md" style={pal.text}> + <Trans> + We ran out of posts from your follows. Here's the latest from{' '} + <TextLink + type="md-medium" + href="/profile/bsky.app/feed/whats-hot" + text="Discover" + style={pal.link} + /> + . + </Trans> + </Text> + </View> + </View> + ) +} diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index cd9f26463..04753fe6c 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -30,6 +30,8 @@ import {useSession} from '#/state/session' import {STALE} from '#/state/queries' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' +import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home' const LOADING_ITEM = {_reactKey: '__loading__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} @@ -265,6 +267,12 @@ let Feed = ({ ) } else if (item === LOADING_ITEM) { return <PostFeedLoadingPlaceholder /> + } else if (item.rootUri === FALLBACK_MARKER_POST.post.uri) { + // HACK + // tell the user we fell back to discover + // see home.ts (feed api) for more info + // -prf + return <DiscoverFallbackHeader /> } return <FeedSlice slice={item} /> }, diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index dcbec7cb4..4f898767d 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -1,6 +1,5 @@ import React, {ComponentProps, memo, useMemo} from 'react' import { - Linking, GestureResponderEvent, Platform, StyleProp, @@ -31,6 +30,7 @@ import {sanitizeUrl} from '@braintree/sanitize-url' import {PressableWithHover} from './PressableWithHover' import FixedTouchableHighlight from '../pager/FixedTouchableHighlight' import {useModalControls} from '#/state/modals' +import {useOpenLink} from '#/state/preferences/in-app-browser' type Event = | React.MouseEvent<HTMLAnchorElement, MouseEvent> @@ -65,6 +65,7 @@ export const Link = memo(function Link({ const {closeModal} = useModalControls() const navigation = useNavigation<NavigationProp>() const anchorHref = asAnchor ? sanitizeUrl(href) : undefined + const openLink = useOpenLink() const onPress = React.useCallback( (e?: Event) => { @@ -74,11 +75,12 @@ export const Link = memo(function Link({ navigation, sanitizeUrl(href), navigationAction, + openLink, e, ) } }, - [closeModal, navigation, navigationAction, href], + [closeModal, navigation, navigationAction, href, openLink], ) if (noFeedback) { @@ -172,6 +174,7 @@ export const TextLink = memo(function TextLink({ const {...props} = useLinkProps({to: sanitizeUrl(href)}) const navigation = useNavigation<NavigationProp>() const {openModal, closeModal} = useModalControls() + const openLink = useOpenLink() if (warnOnMismatchingLabel && typeof text !== 'string') { console.error('Unable to detect mismatching label') @@ -200,6 +203,7 @@ export const TextLink = memo(function TextLink({ navigation, sanitizeUrl(href), navigationAction, + openLink, e, ) }, @@ -212,6 +216,7 @@ export const TextLink = memo(function TextLink({ text, warnOnMismatchingLabel, navigationAction, + openLink, ], ) const hrefAttrs = useMemo(() => { @@ -317,6 +322,7 @@ function onPressInner( navigation: NavigationProp, href: string, navigationAction: 'push' | 'replace' | 'navigate' = 'push', + openLink: (href: string) => void, e?: Event, ) { let shouldHandle = false @@ -345,7 +351,7 @@ function onPressInner( if (shouldHandle) { href = convertBskyAppUrlIfNeeded(href) if (newTab || href.startsWith('http') || href.startsWith('mailto')) { - Linking.openURL(href) + openLink(href) } else { closeModal() // close any active modals diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 00a102e7b..6f168a293 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -22,7 +22,6 @@ import {Link} from '../Link' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' import {useLightboxControls, ImagesLightbox} from '#/state/lightbox' import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {MaybeQuoteEmbed} from './QuoteEmbed' import {AutoSizedImage} from '../images/AutoSizedImage' @@ -51,7 +50,6 @@ export function PostEmbeds({ }) { const pal = usePalette('default') const {openLightbox} = useLightboxControls() - const {isMobile} = useWebMediaQueries() // quote post with media // = @@ -129,10 +127,7 @@ export function PostEmbeds({ dimensionsHint={aspectRatio} onPress={() => _openLightbox(0)} onPressIn={() => onPressIn(0)} - style={[ - styles.singleImage, - isMobile && styles.singleImageMobile, - ]}> + style={[styles.singleImage]}> {alt === '' ? null : ( <View style={styles.altContainer}> <Text style={styles.alt} accessible={false}> @@ -151,11 +146,7 @@ export function PostEmbeds({ images={embed.images} onPress={_openLightbox} onPressIn={onPressIn} - style={ - embed.images.length === 1 - ? [styles.singleImage, isMobile && styles.singleImageMobile] - : undefined - } + style={embed.images.length === 1 ? [styles.singleImage] : undefined} /> </View> ) @@ -188,10 +179,6 @@ const styles = StyleSheet.create({ }, singleImage: { borderRadius: 8, - maxHeight: 1000, - }, - singleImageMobile: { - maxHeight: 500, }, extOuter: { borderWidth: 1, |