diff options
Diffstat (limited to 'src/view/com')
51 files changed, 2023 insertions, 888 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..f6eedc2eb 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -1,39 +1,34 @@ 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 RNPickerSelect from 'react-native-picker-select' +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 {isAndroid, 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' +import {COUNTRY_CODES} from '#/lib/country-codes' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' -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 +38,253 @@ export function Step2({ }) { const pal = usePalette('default') const {_} = useLingui() - const {openModal} = useModalControls() + const {isMobile} = useWebMediaQueries() + + const onPressRequest = React.useCallback(() => { + if ( + uiState.verificationPhone.length >= 9 && + parsePhoneNumber(uiState.verificationPhone, uiState.phoneCountry) + ) { + requestVerificationCode({uiState, uiDispatch, _}) + } else { + uiDispatch({ + type: 'set-error', + value: _( + msg`There's something wrong with this number. Please choose your country and enter your full phone number!`, + ), + }) + } + }, [uiState, uiDispatch, _]) - const onPressWaitlist = React.useCallback(() => { - openModal({name: 'waitlist'}) - }, [openModal]) + const onPressRetry = React.useCallback(() => { + uiDispatch({type: 'set-has-requested-verification-code', value: false}) + }, [uiDispatch]) - const birthDate = React.useMemo(() => { - return sanitizeDate(uiState.birthDate) - }, [uiState.birthDate]) + const phoneNumberFormatted = React.useMemo( + () => + uiState.hasRequestedVerificationCode + ? parsePhoneNumber( + uiState.verificationPhone, + uiState.phoneCountry, + )?.formatInternational() + : '', + [ + uiState.hasRequestedVerificationCode, + uiState.verificationPhone, + uiState.phoneCountry, + ], + ) return ( <View> - <StepHeader step="2" title={_(msg`Your account`)} /> - - {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> - )} + <StepHeader uiState={uiState} title={_(msg`SMS verification`)} /> - {!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> + <View style={s.pb10}> + <Text + type="md-medium" + style={[pal.text, s.mb2]} + nativeID="phoneCountry"> + <Trans>Country</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 waitlist`)} - accessibilityLabelledBy="email" - autoCapitalize="none" - autoComplete="off" - autoCorrect={false} - /> + <View + style={[ + {position: 'relative'}, + isAndroid && { + borderWidth: 1, + borderColor: pal.border.borderColor, + borderRadius: 4, + }, + ]}> + <RNPickerSelect + placeholder={{}} + value={uiState.phoneCountry} + onValueChange={value => + uiDispatch({type: 'set-phone-country', value}) + } + items={COUNTRY_CODES.filter(l => Boolean(l.code2)).map(l => ({ + label: l.name, + value: l.code2, + key: l.code2, + }))} + style={{ + inputAndroid: { + backgroundColor: pal.view.backgroundColor, + color: pal.text.color, + fontSize: 21, + letterSpacing: 0.5, + fontWeight: '500', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 4, + }, + inputIOS: { + backgroundColor: pal.view.backgroundColor, + color: pal.text.color, + fontSize: 14, + letterSpacing: 0.5, + fontWeight: '500', + paddingHorizontal: 14, + paddingVertical: 8, + borderWidth: 1, + borderColor: pal.border.borderColor, + borderRadius: 4, + }, + inputWeb: { + // @ts-ignore web only + cursor: 'pointer', + '-moz-appearance': 'none', + '-webkit-appearance': 'none', + appearance: 'none', + outline: 0, + borderWidth: 1, + borderColor: pal.border.borderColor, + backgroundColor: pal.view.backgroundColor, + color: pal.text.color, + fontSize: 14, + letterSpacing: 0.5, + fontWeight: '500', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 4, + }, + }} + accessibilityLabel={_(msg`Select your phone's country`)} + accessibilityHint="" + accessibilityLabelledBy="phoneCountry" + /> + <View + style={{ + position: 'absolute', + top: 1, + right: 1, + bottom: 1, + width: 40, + pointerEvents: 'none', + alignItems: 'center', + justifyContent: 'center', + }}> + <FontAwesomeIcon + icon="chevron-down" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + </View> </View> <View style={s.pb20}> <Text type="md-medium" style={[pal.text, s.mb2]} - nativeID="password"> - <Trans>Password</Trans> + nativeID="phoneNumber"> + <Trans>Phone number</Trans> </Text> <TextInput - testID="passwordInput" - icon="lock" - placeholder={_(msg`Choose your password`)} - value={uiState.password} + testID="phoneInput" + icon="phone" + placeholder={_(msg`Enter your phone number`)} + value={uiState.verificationPhone} editable - secureTextEntry - onChange={value => uiDispatch({type: 'set-password', value})} - accessibilityLabel={_(msg`Password`)} - accessibilityHint={_(msg`Set password`)} - accessibilityLabelledBy="password" + onChange={value => + uiDispatch({type: 'set-verification-phone', value}) + } + accessibilityLabel={_(msg`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="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 + 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="codeInput" + icon="hashtag" + placeholder={_(msg`XXXXXX`)} + value={uiState.verificationCode} + editable + 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="one-time-code" + textContentType="oneTimeCode" + autoCorrect={false} + autoFocus={true} /> + <Text type="sm" style={[pal.textLight, s.mt5]}> + <Trans> + Please enter the verification code sent to{' '} + {phoneNumberFormatted}. + </Trans> + </Text> </View> - - {uiState.serviceDescription && ( - <Policies - serviceDescription={uiState.serviceDescription} - needsGuardian={!is18(uiState)} - /> - )} </> )} + {uiState.error ? ( <ErrorMessage message={uiState.error} style={styles.error} /> ) : undefined} @@ -179,11 +297,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..81cebc118 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, {CountryCode} 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,10 @@ export type CreateAccountAction = | {type: 'set-invite-code'; value: string} | {type: 'set-email'; value: string} | {type: 'set-password'; value: string} + | {type: 'set-phone-country'; value: CountryCode} + | {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 +49,10 @@ export interface CreateAccountState { inviteCode: string email: string password: string + phoneCountry: CountryCode + verificationPhone: string + verificationCode: string + hasRequestedVerificationCode: boolean handle: string birthDate: Date @@ -50,6 +60,7 @@ export interface CreateAccountState { canBack: boolean canNext: boolean isInviteCodeRequired: boolean + isPhoneVerificationRequired: boolean } export type CreateAccountDispatch = (action: CreateAccountAction) => void @@ -66,15 +77,55 @@ export function useCreateAccount() { inviteCode: '', email: '', password: '', + phoneCountry: 'US', + 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, + uiState.phoneCountry, + )?.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 +140,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 +188,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 +198,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 +267,22 @@ function createReducer({_}: {_: I18nContext['_']}) { case 'set-password': { return compute({...state, password: action.value}) } + case 'set-phone-country': { + return compute({...state, phoneCountry: 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 +290,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 +300,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 +320,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 +338,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..1ed6b98a5 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' @@ -73,7 +74,7 @@ export const ComposePost = observer(function ComposePost({ }: Props) { const {currentAccount} = useSession() const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) - const {activeModals} = useModals() + const {isModalActive, activeModals} = useModals() const {openModal, closeModal} = useModalControls() const {closeComposer} = useComposerControls() const {track} = useAnalytics() @@ -175,11 +176,11 @@ export const ComposePost = observer(function ComposePost({ [onPressCancel], ) useEffect(() => { - if (isWeb) { + if (isWeb && !isModalActive) { window.addEventListener('keydown', onEscape) return () => window.removeEventListener('keydown', onEscape) } - }, [onEscape]) + }, [onEscape, isModalActive]) const onPressAddLinkCard = useCallback( (uri: string) => { @@ -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/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index ec3a042a3..f2012a630 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -9,7 +9,7 @@ import Hardbreak from '@tiptap/extension-hard-break' import {Mention} from '@tiptap/extension-mention' import {Paragraph} from '@tiptap/extension-paragraph' import {Placeholder} from '@tiptap/extension-placeholder' -import {Text} from '@tiptap/extension-text' +import {Text as TiptapText} from '@tiptap/extension-text' import isEqual from 'lodash.isequal' import {createSuggestion} from './web/Autocomplete' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' @@ -18,6 +18,11 @@ import {Emoji} from './web/EmojiPicker.web' import {LinkDecorator} from './web/LinkDecorator' import {generateJSON} from '@tiptap/html' import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' +import {usePalette} from '#/lib/hooks/usePalette' +import {Portal} from '#/components/Portal' +import {Text} from '../../util/text/Text' +import {Trans} from '@lingui/macro' +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' export interface TextInputRef { focus: () => void @@ -53,7 +58,11 @@ export const TextInput = React.forwardRef(function TextInputImpl( ) { const autocomplete = useActorAutocompleteFn() + const pal = usePalette('default') const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark') + + const [isDropping, setIsDropping] = React.useState(false) + const extensions = React.useMemo( () => [ Document, @@ -68,7 +77,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( Placeholder.configure({ placeholder, }), - Text, + TiptapText, History, Hardbreak, ], @@ -88,6 +97,46 @@ export const TextInput = React.forwardRef(function TextInputImpl( } }, [onPhotoPasted]) + React.useEffect(() => { + const handleDrop = (event: DragEvent) => { + const transfer = event.dataTransfer + if (transfer) { + const items = transfer.items + + getImageFromUri(items, (uri: string) => { + textInputWebEmitter.emit('photo-pasted', uri) + }) + } + + event.preventDefault() + setIsDropping(false) + } + const handleDragEnter = (event: DragEvent) => { + const transfer = event.dataTransfer + + event.preventDefault() + if (transfer && transfer.types.includes('Files')) { + setIsDropping(true) + } + } + const handleDragLeave = (event: DragEvent) => { + event.preventDefault() + setIsDropping(false) + } + + document.body.addEventListener('drop', handleDrop) + document.body.addEventListener('dragenter', handleDragEnter) + document.body.addEventListener('dragover', handleDragEnter) + document.body.addEventListener('dragleave', handleDragLeave) + + return () => { + document.body.removeEventListener('drop', handleDrop) + document.body.removeEventListener('dragenter', handleDragEnter) + document.body.removeEventListener('dragover', handleDragEnter) + document.body.removeEventListener('dragleave', handleDragLeave) + } + }, [setIsDropping]) + const editor = useEditor( { extensions, @@ -177,9 +226,28 @@ export const TextInput = React.forwardRef(function TextInputImpl( })) return ( - <View style={styles.container}> - <EditorContent editor={editor} /> - </View> + <> + <View style={styles.container}> + <EditorContent editor={editor} /> + </View> + + {isDropping && ( + <Portal> + <Animated.View + style={styles.dropContainer} + entering={FadeIn.duration(80)} + exiting={FadeOut.duration(80)}> + <View style={[pal.view, pal.border, styles.dropModal]}> + <Text + type="lg" + style={[pal.text, pal.borderDark, styles.dropText]}> + <Trans>Drop to add images</Trans> + </Text> + </View> + </Animated.View> + </Portal> + )} + </> ) }) @@ -210,6 +278,33 @@ const styles = StyleSheet.create({ marginLeft: 8, marginBottom: 10, }, + dropContainer: { + backgroundColor: '#0007', + pointerEvents: 'none', + alignItems: 'center', + justifyContent: 'center', + // @ts-ignore web only -prf + position: 'fixed', + padding: 16, + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + dropModal: { + // @ts-ignore web only + boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px', + padding: 8, + borderWidth: 1, + borderRadius: 16, + }, + dropText: { + paddingVertical: 44, + paddingHorizontal: 36, + borderStyle: 'dashed', + borderRadius: 8, + borderWidth: 2, + }, }) function getImageFromUri( @@ -218,25 +313,25 @@ function getImageFromUri( ) { for (let index = 0; index < items.length; index++) { const item = items[index] - const {kind, type} = item + const type = item.type if (type === 'text/plain') { + console.log('hit') item.getAsString(async itemString => { if (isUriImage(itemString)) { const response = await fetch(itemString) const blob = await response.blob() - blobToDataUri(blob).then(callback, err => console.error(err)) + + if (blob.type.startsWith('image/')) { + blobToDataUri(blob).then(callback, err => console.error(err)) + } } }) - } - - if (kind === 'file') { + } else if (type.startsWith('image/')) { const file = item.getAsFile() - if (file instanceof Blob) { - blobToDataUri(new Blob([file], {type: item.type})).then(callback, err => - console.error(err), - ) + if (file) { + blobToDataUri(file).then(callback, err => console.error(err)) } } } diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx index 6d16403ff..149362116 100644 --- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx +++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx @@ -121,7 +121,8 @@ export function EmojiPicker({state, close}: IProps) { const styles = StyleSheet.create({ mask: { - position: 'absolute', + // @ts-ignore web ony + position: 'fixed', top: 0, left: 0, right: 0, diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts index ef3958c9d..fc7218d5d 100644 --- a/src/view/com/composer/useExternalLinkFetch.ts +++ b/src/view/com/composer/useExternalLinkFetch.ts @@ -18,6 +18,7 @@ import {POST_IMG_MAX} from 'lib/constants' import {logger} from '#/logger' import {getAgent} from '#/state/session' import {useGetPost} from '#/state/queries/post' +import {useFetchDid} from '#/state/queries/handle' export function useExternalLinkFetch({ setQuote, @@ -28,6 +29,7 @@ export function useExternalLinkFetch({ undefined, ) const getPost = useGetPost() + const fetchDid = useFetchDid() useEffect(() => { let aborted = false @@ -55,7 +57,7 @@ export function useExternalLinkFetch({ }, ) } else if (isBskyCustomFeedUrl(extLink.uri)) { - getFeedAsEmbed(getAgent(), extLink.uri).then( + getFeedAsEmbed(getAgent(), fetchDid, extLink.uri).then( ({embed, meta}) => { if (aborted) { return @@ -73,7 +75,7 @@ export function useExternalLinkFetch({ }, ) } else if (isBskyListUrl(extLink.uri)) { - getListAsEmbed(getAgent(), extLink.uri).then( + getListAsEmbed(getAgent(), fetchDid, extLink.uri).then( ({embed, meta}) => { if (aborted) { return @@ -133,7 +135,7 @@ export function useExternalLinkFetch({ }) } return cleanup - }, [extLink, setQuote, getPost]) + }, [extLink, setQuote, getPost, fetchDid]) return {extLink, setExtLink} } diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 49f280981..9595e77e5 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -210,18 +210,9 @@ function useHeaderOffset() { const {isDesktop, isTablet} = useWebMediaQueries() const {fontScale} = useWindowDimensions() const {hasSession} = useSession() - - if (isDesktop) { + if (isDesktop || isTablet) { return 0 } - if (isTablet) { - if (hasSession) { - return 50 - } else { - return 0 - } - } - if (hasSession) { const navBarPad = 16 const navBarText = 21 * fontScale diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index 2271bb9fb..38f2c89c9 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {Pressable, StyleSheet, View} from 'react-native' +import {LayoutAnimation, StyleSheet, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import ImageView from './ImageViewing' import {shareImageModal, saveImageToMediaLibrary} from 'lib/media/manip' @@ -105,15 +105,21 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) { return ( <View style={[styles.footer]}> {altText ? ( - <Pressable - onPress={() => setAltExpanded(!isAltExpanded)} - accessibilityRole="button"> + <View accessibilityRole="button" style={styles.footerText}> <Text - style={[s.gray3, styles.footerText]} - numberOfLines={isAltExpanded ? undefined : 3}> + style={[s.gray3]} + numberOfLines={isAltExpanded ? undefined : 3} + selectable + onPress={() => { + LayoutAnimation.configureNext({ + duration: 300, + update: {type: 'spring', springDamping: 0.7}, + }) + setAltExpanded(prev => !prev) + }}> {altText} </Text> - </Pressable> + </View> ) : null} <View style={styles.footerBtns}> <Button diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index a258d25ab..fb97c30a4 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -1,13 +1,17 @@ import React, {useCallback, useEffect, useState} from 'react' import { Image, + ImageStyle, TouchableOpacity, TouchableWithoutFeedback, StyleSheet, View, Pressable, } from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' import {colors, s} from 'lib/styles' import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader' import {Text} from '../util/text/Text' @@ -19,6 +23,7 @@ import { ImagesLightbox, ProfileImageLightbox, } from '#/state/lightbox' +import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' interface Img { uri: string @@ -28,8 +33,10 @@ interface Img { export function Lightbox() { const {activeLightbox} = useLightbox() const {closeLightbox} = useLightboxControls() + const isActive = !!activeLightbox + useWebBodyScrollLock(isActive) - if (!activeLightbox) { + if (!isActive) { return null } @@ -116,7 +123,7 @@ function LightboxInner({ <Image accessibilityIgnoresInvertColors source={imgs[index]} - style={styles.image} + style={styles.image as ImageStyle} accessibilityLabel={imgs[index].alt} accessibilityHint="" /> @@ -129,7 +136,7 @@ function LightboxInner({ accessibilityHint=""> <FontAwesomeIcon icon="angle-left" - style={styles.icon} + style={styles.icon as FontAwesomeIconStyle} size={40} /> </TouchableOpacity> @@ -143,7 +150,7 @@ function LightboxInner({ accessibilityHint=""> <FontAwesomeIcon icon="angle-right" - style={styles.icon} + style={styles.icon as FontAwesomeIconStyle} size={40} /> </TouchableOpacity> @@ -178,7 +185,8 @@ function LightboxInner({ const styles = StyleSheet.create({ mask: { - position: 'absolute', + // @ts-ignore + position: 'fixed', top: 0, left: 0, width: '100%', 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/AppealLabel.tsx b/src/view/com/modals/AppealLabel.tsx index 1a1947a9a..b0aaaf625 100644 --- a/src/view/com/modals/AppealLabel.tsx +++ b/src/view/com/modals/AppealLabel.tsx @@ -38,7 +38,7 @@ export function Component(props: ReportComponentProps) { ? 'com.atproto.repo.strongRef' : 'com.atproto.admin.defs#repoRef' await getAgent().createModerationReport({ - reasonType: ComAtprotoModerationDefs.REASONOTHER, + reasonType: ComAtprotoModerationDefs.REASONAPPEAL, subject: { $type, ...props, diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx index bd1eb3393..0e11fcffd 100644 --- a/src/view/com/modals/CreateOrEditList.tsx +++ b/src/view/com/modals/CreateOrEditList.tsx @@ -8,7 +8,11 @@ import { TouchableOpacity, View, } from 'react-native' -import {AppBskyGraphDefs} from '@atproto/api' +import { + AppBskyGraphDefs, + AppBskyRichtextFacet, + RichText as RichTextAPI, +} from '@atproto/api' import LinearGradient from 'react-native-linear-gradient' import {Image as RNImage} from 'react-native-image-crop-picker' import {Text} from '../util/text/Text' @@ -30,6 +34,9 @@ import { useListCreateMutation, useListMetadataMutation, } from '#/state/queries/list' +import {richTextToString} from '#/lib/strings/rich-text-helpers' +import {shortenLinks} from '#/lib/strings/rich-text-manip' +import {getAgent} from '#/state/session' const MAX_NAME = 64 // todo const MAX_DESCRIPTION = 300 // todo @@ -68,12 +75,42 @@ export function Component({ const [isProcessing, setProcessing] = useState<boolean>(false) const [name, setName] = useState<string>(list?.name || '') - const [description, setDescription] = useState<string>( - list?.description || '', - ) + + const [descriptionRt, setDescriptionRt] = useState<RichTextAPI>(() => { + const text = list?.description + const facets = list?.descriptionFacets + + if (!text || !facets) { + return new RichTextAPI({text: text || ''}) + } + + // We want to be working with a blank state here, so let's get the + // serialized version and turn it back into a RichText + const serialized = richTextToString(new RichTextAPI({text, facets}), false) + + const richText = new RichTextAPI({text: serialized}) + richText.detectFacetsWithoutResolution() + + return richText + }) + const graphemeLength = useMemo(() => { + return shortenLinks(descriptionRt).graphemeLength + }, [descriptionRt]) + const isDescriptionOver = graphemeLength > MAX_DESCRIPTION + const [avatar, setAvatar] = useState<string | undefined>(list?.avatar) const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>() + const onDescriptionChange = useCallback( + (newText: string) => { + const richText = new RichTextAPI({text: newText}) + richText.detectFacetsWithoutResolution() + + setDescriptionRt(richText) + }, + [setDescriptionRt], + ) + const onPressCancel = useCallback(() => { closeModal() }, [closeModal]) @@ -113,11 +150,31 @@ export function Component({ setError('') } try { + let richText = new RichTextAPI( + {text: descriptionRt.text.trimEnd()}, + {cleanNewlines: true}, + ) + + await richText.detectFacets(getAgent()) + richText = shortenLinks(richText) + + // filter out any mention facets that didn't map to a user + richText.facets = richText.facets?.filter(facet => { + const mention = facet.features.find(feature => + AppBskyRichtextFacet.isMention(feature), + ) + if (mention && !mention.did) { + return false + } + return true + }) + if (list) { await listMetadataMutation.mutateAsync({ uri: list.uri, name: nameTrimmed, - description: description.trim(), + description: richText.text, + descriptionFacets: richText.facets, avatar: newAvatar, }) Toast.show( @@ -130,7 +187,8 @@ export function Component({ const res = await listCreateMutation.mutateAsync({ purpose: activePurpose, name, - description, + description: richText.text, + descriptionFacets: richText.facets, avatar: newAvatar, }) Toast.show( @@ -163,7 +221,7 @@ export function Component({ activePurpose, isCurateList, name, - description, + descriptionRt, newAvatar, list, listMetadataMutation, @@ -182,19 +240,17 @@ export function Component({ ]} testID="createOrEditListModal"> <Text style={[styles.title, pal.text]}> - <Trans> - {isCurateList ? ( - list ? ( - <Trans>Edit User List</Trans> - ) : ( - <Trans>New User List</Trans> - ) - ) : list ? ( - <Trans>Edit Moderation List</Trans> + {isCurateList ? ( + list ? ( + <Trans>Edit User List</Trans> ) : ( - <Trans>New Moderation List</Trans> - )} - </Trans> + <Trans>New User List</Trans> + ) + ) : list ? ( + <Trans>Edit Moderation List</Trans> + ) : ( + <Trans>New Moderation List</Trans> + )} </Text> {error !== '' && ( <View style={styles.errorContainer}> @@ -214,9 +270,11 @@ export function Component({ </View> <View style={styles.form}> <View> - <Text style={[styles.label, pal.text]} nativeID="list-name"> - <Trans>List Name</Trans> - </Text> + <View style={styles.labelWrapper}> + <Text style={[styles.label, pal.text]} nativeID="list-name"> + <Trans>List Name</Trans> + </Text> + </View> <TextInput testID="editNameInput" style={[styles.textInput, pal.border, pal.text]} @@ -235,9 +293,17 @@ export function Component({ /> </View> <View style={s.pb10}> - <Text style={[styles.label, pal.text]} nativeID="list-description"> - <Trans>Description</Trans> - </Text> + <View style={styles.labelWrapper}> + <Text + style={[styles.label, pal.text]} + nativeID="list-description"> + <Trans>Description</Trans> + </Text> + <Text + style={[!isDescriptionOver ? pal.textLight : s.red3, s.f13]}> + {graphemeLength}/{MAX_DESCRIPTION} + </Text> + </View> <TextInput testID="editDescriptionInput" style={[styles.textArea, pal.border, pal.text]} @@ -249,8 +315,8 @@ export function Component({ placeholderTextColor={colors.gray4} keyboardAppearance={theme.colorScheme} multiline - value={description} - onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} + value={descriptionRt.text} + onChangeText={onDescriptionChange} accessible={true} accessibilityLabel={_(msg`Description`)} accessibilityHint="" @@ -264,7 +330,8 @@ export function Component({ ) : ( <TouchableOpacity testID="saveBtn" - style={s.mt10} + style={[s.mt10, isDescriptionOver && s.dimmed]} + disabled={isDescriptionOver} onPress={onPressSave} accessibilityRole="button" accessibilityLabel={_(msg`Save`)} @@ -273,7 +340,7 @@ export function Component({ colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} end={{x: 1, y: 1}} - style={[styles.btn]}> + style={styles.btn}> <Text style={[s.white, s.bold]}> <Trans context="action">Save</Trans> </Text> @@ -307,12 +374,18 @@ const styles = StyleSheet.create({ fontSize: 24, marginBottom: 18, }, - label: { - fontWeight: 'bold', + labelWrapper: { + flexDirection: 'row', + gap: 8, + alignItems: 'center', + justifyContent: 'space-between', paddingHorizontal: 4, paddingBottom: 4, marginTop: 20, }, + label: { + fontWeight: 'bold', + }, form: { paddingHorizontal: 6, }, 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/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index c43a8a6ce..d79663746 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -3,6 +3,7 @@ import {TouchableWithoutFeedback, StyleSheet, View} from 'react-native' import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' import {useModals, useModalControls} from '#/state/modals' import type {Modal as ModalIface} from '#/state/modals' @@ -38,6 +39,7 @@ import * as EmbedConsentModal from './EmbedConsent' export function ModalsContainer() { const {isModalActive, activeModals} = useModals() + useWebBodyScrollLock(isModalActive) if (!isModalActive) { return null @@ -63,7 +65,11 @@ function Modal({modal}: {modal: ModalIface}) { } const onPressMask = () => { - if (modal.name === 'crop-image' || modal.name === 'edit-image') { + if ( + modal.name === 'crop-image' || + modal.name === 'edit-image' || + modal.name === 'alt-text-image' + ) { return // dont close on mask presses during crop } closeModal() @@ -162,7 +168,8 @@ function Modal({modal}: {modal: ModalIface}) { const styles = StyleSheet.create({ mask: { - position: 'absolute', + // @ts-ignore + position: 'fixed', top: 0, left: 0, width: '100%', diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index e9d8b63e2..0dfac2a83 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -167,11 +167,9 @@ let FeedItem = ({ icon = 'user-plus' iconStyle = [s.blue3 as FontAwesomeIconStyle] } else if (item.type === 'feedgen-like') { - action = _( - msg`liked your custom feed${ - item.subjectUri ? ` '${new AtUri(item.subjectUri).rkey}'` : '' - }`, - ) + action = item.subjectUri + ? _(msg`liked your custom feed '${new AtUri(item.subjectUri).rkey}'`) + : _(msg`liked your custom feed`) icon = 'HeartIconSolid' iconStyle = [ s.likeColor as FontAwesomeIconStyle, diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index 57c83f17c..385da5544 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -117,7 +117,7 @@ function FeedsTabBarTablet( return ( // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf <Animated.View - style={[pal.view, styles.tabBar, headerMinimalShellTransform]} + style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]} onLayout={e => { headerHeight.value = e.nativeEvent.layout.height }}> @@ -134,13 +134,16 @@ function FeedsTabBarTablet( const styles = StyleSheet.create({ tabBar: { - position: 'absolute', + // @ts-ignore Web only + position: 'sticky', zIndex: 1, // @ts-ignore Web only -prf - left: 'calc(50% - 299px)', - width: 598, + left: 'calc(50% - 300px)', + width: 600, top: 0, flexDirection: 'row', alignItems: 'center', + borderLeftWidth: 1, + borderRightWidth: 1, }, }) diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index 2c5ba5dfb..b9959a6d9 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" @@ -123,7 +142,8 @@ export function FeedsTabBar( const styles = StyleSheet.create({ tabBar: { - position: 'absolute', + // @ts-ignore web-only + position: isWeb ? 'fixed' : 'absolute', zIndex: 1, left: 0, right: 0, diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index 61c3609f2..834b1c0d0 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -17,6 +17,7 @@ export interface PagerRef { export interface RenderTabBarFnProps { selectedPage: number onSelect?: (index: number) => void + tabBarAnchor?: JSX.Element | null | undefined // Ignored on native. } export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx index 3b5e9164a..dde799e42 100644 --- a/src/view/com/pager/Pager.web.tsx +++ b/src/view/com/pager/Pager.web.tsx @@ -1,10 +1,12 @@ import React from 'react' +import {flushSync} from 'react-dom' import {View} from 'react-native' import {s} from 'lib/styles' export interface RenderTabBarFnProps { selectedPage: number onSelect?: (index: number) => void + tabBarAnchor?: JSX.Element } export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element @@ -27,6 +29,8 @@ export const Pager = React.forwardRef(function PagerImpl( ref, ) { const [selectedPage, setSelectedPage] = React.useState(initialPage) + const scrollYs = React.useRef<Array<number | null>>([]) + const anchorRef = React.useRef(null) React.useImperativeHandle(ref, () => ({ setPage: (index: number) => setSelectedPage(index), @@ -34,11 +38,36 @@ export const Pager = React.forwardRef(function PagerImpl( const onTabBarSelect = React.useCallback( (index: number) => { - setSelectedPage(index) - onPageSelected?.(index) - onPageSelecting?.(index) + const scrollY = window.scrollY + // We want to determine if the tabbar is already "sticking" at the top (in which + // case we should preserve and restore scroll), or if it is somewhere below in the + // viewport (in which case a scroll jump would be jarring). We determine this by + // measuring where the "anchor" element is (which we place just above the tabbar). + let anchorTop = anchorRef.current + ? (anchorRef.current as Element).getBoundingClientRect().top + : -scrollY // If there's no anchor, treat the top of the page as one. + const isSticking = anchorTop <= 5 // This would be 0 if browser scrollTo() was reliable. + + if (isSticking) { + scrollYs.current[selectedPage] = window.scrollY + } else { + scrollYs.current[selectedPage] = null + } + flushSync(() => { + setSelectedPage(index) + onPageSelected?.(index) + onPageSelecting?.(index) + }) + if (isSticking) { + const restoredScrollY = scrollYs.current[index] + if (restoredScrollY != null) { + window.scrollTo(0, restoredScrollY) + } else { + window.scrollTo(0, scrollY + anchorTop) + } + } }, - [setSelectedPage, onPageSelected, onPageSelecting], + [selectedPage, setSelectedPage, onPageSelected, onPageSelecting], ) return ( @@ -46,21 +75,11 @@ export const Pager = React.forwardRef(function PagerImpl( {tabBarPosition === 'top' && renderTabBar({ selectedPage, + tabBarAnchor: <View ref={anchorRef} />, onSelect: onTabBarSelect, })} {React.Children.map(children, (child, i) => ( - <View - style={ - selectedPage === i - ? s.flex1 - : { - position: 'absolute', - pointerEvents: 'none', - // @ts-ignore web-only - visibility: 'hidden', - } - } - key={`page-${i}`}> + <View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}> {child} </View> ))} diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 158940d67..279b607ad 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -18,7 +18,6 @@ import Animated, { } from 'react-native-reanimated' import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {TabBar} from './TabBar' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {ListMethods} from '../util/List' import {ScrollProvider} from '#/lib/ScrollContext' @@ -235,7 +234,6 @@ let PagerTabBar = ({ onCurrentPageSelected?: (index: number) => void onSelect?: (index: number) => void }): React.ReactNode => { - const {isMobile} = useWebMediaQueries() const headerTransform = useAnimatedStyle(() => ({ transform: [ { @@ -246,10 +244,7 @@ let PagerTabBar = ({ return ( <Animated.View pointerEvents="box-none" - style={[ - isMobile ? styles.tabBarMobile : styles.tabBarDesktop, - headerTransform, - ]}> + style={[styles.tabBarMobile, headerTransform]}> <View onLayout={onHeaderOnlyLayout} pointerEvents="box-none"> {renderHeader?.()} </View> @@ -325,14 +320,6 @@ const styles = StyleSheet.create({ left: 0, width: '100%', }, - tabBarDesktop: { - position: 'absolute', - zIndex: 1, - top: 0, - // @ts-ignore Web only -prf - left: 'calc(50% - 299px)', - width: 598, - }, }) function noop() { diff --git a/src/view/com/pager/PagerWithHeader.web.tsx b/src/view/com/pager/PagerWithHeader.web.tsx new file mode 100644 index 000000000..0a18a9e7d --- /dev/null +++ b/src/view/com/pager/PagerWithHeader.web.tsx @@ -0,0 +1,194 @@ +import * as React from 'react' +import {FlatList, ScrollView, StyleSheet, View} from 'react-native' +import {useAnimatedRef} from 'react-native-reanimated' +import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' +import {TabBar} from './TabBar' +import {usePalette} from '#/lib/hooks/usePalette' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {ListMethods} from '../util/List' + +export interface PagerWithHeaderChildParams { + headerHeight: number + isFocused: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | ScrollView | null> +} + +export interface PagerWithHeaderProps { + testID?: string + children: + | (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[] + | ((props: PagerWithHeaderChildParams) => JSX.Element) + items: string[] + isHeaderReady: boolean + renderHeader?: () => JSX.Element + initialPage?: number + onPageSelected?: (index: number) => void + onCurrentPageSelected?: (index: number) => void +} +export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( + function PageWithHeaderImpl( + { + children, + testID, + items, + renderHeader, + initialPage, + onPageSelected, + onCurrentPageSelected, + }: PagerWithHeaderProps, + ref, + ) { + const [currentPage, setCurrentPage] = React.useState(0) + + const renderTabBar = React.useCallback( + (props: RenderTabBarFnProps) => { + return ( + <PagerTabBar + items={items} + renderHeader={renderHeader} + currentPage={currentPage} + onCurrentPageSelected={onCurrentPageSelected} + onSelect={props.onSelect} + tabBarAnchor={props.tabBarAnchor} + testID={testID} + /> + ) + }, + [items, renderHeader, currentPage, onCurrentPageSelected, testID], + ) + + const onPageSelectedInner = React.useCallback( + (index: number) => { + setCurrentPage(index) + onPageSelected?.(index) + }, + [onPageSelected, setCurrentPage], + ) + + const onPageSelecting = React.useCallback((index: number) => { + setCurrentPage(index) + }, []) + + return ( + <Pager + ref={ref} + testID={testID} + initialPage={initialPage} + onPageSelected={onPageSelectedInner} + onPageSelecting={onPageSelecting} + renderTabBar={renderTabBar} + tabBarPosition="top"> + {toArray(children) + .filter(Boolean) + .map((child, i) => { + return ( + <View key={i} collapsable={false}> + <PagerItem isFocused={i === currentPage} renderTab={child} /> + </View> + ) + })} + </Pager> + ) + }, +) + +let PagerTabBar = ({ + currentPage, + items, + testID, + renderHeader, + onCurrentPageSelected, + onSelect, + tabBarAnchor, +}: { + currentPage: number + items: string[] + testID?: string + renderHeader?: () => JSX.Element + onCurrentPageSelected?: (index: number) => void + onSelect?: (index: number) => void + tabBarAnchor?: JSX.Element | null | undefined +}): React.ReactNode => { + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + return ( + <> + <View style={[!isMobile && styles.headerContainerDesktop, pal.border]}> + {renderHeader?.()} + </View> + {tabBarAnchor} + <View + style={[ + styles.tabBarContainer, + isMobile + ? styles.tabBarContainerMobile + : styles.tabBarContainerDesktop, + pal.border, + ]}> + <TabBar + testID={testID} + items={items} + selectedPage={currentPage} + onSelect={onSelect} + onPressSelected={onCurrentPageSelected} + /> + </View> + </> + ) +} +PagerTabBar = React.memo(PagerTabBar) + +function PagerItem({ + isFocused, + renderTab, +}: { + isFocused: boolean + renderTab: ((props: PagerWithHeaderChildParams) => JSX.Element) | null +}) { + const scrollElRef = useAnimatedRef() + if (renderTab == null) { + return null + } + return renderTab({ + headerHeight: 0, + isFocused, + scrollElRef: scrollElRef as React.MutableRefObject< + ListMethods | ScrollView | null + >, + }) +} + +const styles = StyleSheet.create({ + headerContainerDesktop: { + marginLeft: 'auto', + marginRight: 'auto', + width: 600, + borderLeftWidth: 1, + borderRightWidth: 1, + }, + tabBarContainer: { + // @ts-ignore web-only + position: 'sticky', + overflow: 'hidden', + top: 0, + zIndex: 1, + }, + tabBarContainerDesktop: { + marginLeft: 'auto', + marginRight: 'auto', + width: 600, + borderLeftWidth: 1, + borderRightWidth: 1, + }, + tabBarContainerMobile: { + paddingLeft: 14, + paddingRight: 14, + }, +}) + +function toArray<T>(v: T | T[]): T[] { + if (Array.isArray(v)) { + return v + } + return [v] +} diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index cb7fd3f41..072ef7e33 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -36,11 +36,13 @@ import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import { UsePreferencesQueryResponse, + useModerationOpts, usePreferencesQuery, } from '#/state/queries/preferences' import {useSession} from '#/state/session' -import {isNative} from '#/platform/detection' +import {isAndroid, isNative} from '#/platform/detection' import {logger} from '#/logger' +import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} @@ -79,14 +81,30 @@ export function PostThread({ data: thread, } = usePostThreadQuery(uri) const {data: preferences} = usePreferencesQuery() + const rootPost = thread?.type === 'post' ? thread.post : undefined const rootPostRecord = thread?.type === 'post' ? thread.record : undefined + const moderationOpts = useModerationOpts() + const isNoPwi = React.useMemo(() => { + const mod = + rootPost && moderationOpts + ? moderatePost(rootPost, moderationOpts) + : undefined + + const cause = mod?.content.cause + + return cause + ? cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated' + : false + }, [rootPost, moderationOpts]) + useSetTitle( - rootPost && - `${sanitizeDisplayName( - rootPost.author.displayName || `@${rootPost.author.handle}`, - )}: "${rootPostRecord?.text}"`, + rootPost && !isNoPwi + ? `${sanitizeDisplayName( + rootPost.author.displayName || `@${rootPost.author.handle}`, + )}: "${rootPostRecord!.text}"` + : '', ) useEffect(() => { if (rootPost) { @@ -139,7 +157,7 @@ function PostThreadLoaded({ const {hasSession} = useSession() const {_} = useLingui() const pal = usePalette('default') - const {isTablet, isDesktop} = useWebMediaQueries() + const {isTablet, isDesktop, isTabletOrMobile} = useWebMediaQueries() const ref = useRef<ListMethods>(null) const highlightedPostRef = useRef<View | null>(null) const needsScrollAdjustment = useRef<boolean>( @@ -157,7 +175,11 @@ function PostThreadLoaded({ const posts = React.useMemo(() => { let arr = [TOP_COMPONENT].concat( Array.from( - flattenThreadSkeleton(sortThread(thread, threadViewPrefs), hasSession), + flattenThreadSkeleton( + sortThread(thread, threadViewPrefs), + hasSession, + treeView, + ), ), ) if (arr.length > maxVisible) { @@ -167,7 +189,7 @@ function PostThreadLoaded({ arr.push(BOTTOM_COMPONENT) } return arr - }, [thread, maxVisible, threadViewPrefs, hasSession]) + }, [thread, treeView, maxVisible, threadViewPrefs, hasSession]) /** * NOTE @@ -197,17 +219,35 @@ function PostThreadLoaded({ // wait for loading to finish if (thread.type === 'post' && !!thread.parent) { - highlightedPostRef.current?.measure( - (_x, _y, _width, _height, _pageX, pageY) => { - ref.current?.scrollToOffset({ - animated: false, - offset: pageY - (isDesktop ? 0 : 50), - }) - }, - ) + function onMeasure(pageY: number) { + let spinnerHeight = 0 + if (isDesktop) { + spinnerHeight = 40 + } else if (isTabletOrMobile) { + spinnerHeight = 82 + } + ref.current?.scrollToOffset({ + animated: false, + offset: pageY - spinnerHeight, + }) + } + if (isNative) { + highlightedPostRef.current?.measure( + (_x, _y, _width, _height, _pageX, pageY) => { + onMeasure(pageY) + }, + ) + } else { + // Measure synchronously to avoid a layout jump. + const domNode = highlightedPostRef.current + if (domNode) { + const pageY = (domNode as any as Element).getBoundingClientRect().top + onMeasure(pageY) + } + } needsScrollAdjustment.current = false } - }, [thread, isDesktop]) + }, [thread, isDesktop, isTabletOrMobile]) const onPTR = React.useCallback(async () => { setIsPTRing(true) @@ -280,8 +320,10 @@ function PostThreadLoaded({ // -prf return ( <View + // @ts-ignore web-only style={{ - height: 400, + // Leave enough space below that the scroll doesn't jump + height: isNative ? 400 : '100vh', borderTopWidth: 1, borderColor: pal.colors.border, }} @@ -358,6 +400,7 @@ function PostThreadLoaded({ style={s.hContentRegion} // @ts-ignore our .web version only -prf desktopFixedHeight + removeClippedSubviews={isAndroid ? false : undefined} /> ) } @@ -468,10 +511,11 @@ function isThreadPost(v: unknown): v is ThreadPost { function* flattenThreadSkeleton( node: ThreadNode, hasSession: boolean, + treeView: boolean, ): Generator<YieldedItem, void> { if (node.type === 'post') { if (node.parent) { - yield* flattenThreadSkeleton(node.parent, hasSession) + yield* flattenThreadSkeleton(node.parent, hasSession, treeView) } else if (node.ctx.isParentLoading) { yield PARENT_SPINNER } @@ -484,7 +528,10 @@ function* flattenThreadSkeleton( } if (node.replies?.length) { for (const reply of node.replies) { - yield* flattenThreadSkeleton(reply, hasSession) + yield* flattenThreadSkeleton(reply, hasSession, treeView) + if (!treeView && !node.ctx.isHighlightedPost) { + break + } } } else if (node.ctx.isChildLoading) { yield CHILD_SPINNER diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index cd218a062..a27ee0a58 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -248,10 +248,9 @@ let PostThreadItemLoaded = ({ </View> )} - <Link + <View testID={`postThreadItem-by-${post.author.handle}`} style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} - noFeedback accessible={false}> <PostSandboxWarning /> <View style={styles.layout}> @@ -370,6 +369,7 @@ let PostThreadItemLoaded = ({ richText={richText} lineHeight={1.3} style={s.flex1} + selectable /> </View> ) : undefined} @@ -445,7 +445,7 @@ let PostThreadItemLoaded = ({ /> </View> </View> - </Link> + </View> <WhoCanReply post={post} /> </> ) @@ -706,7 +706,7 @@ function ExpandedPostDetails({ <Text style={pal.textLight}>{niceDate(post.indexedAt)}</Text> {needsTranslation && ( <> - <Text style={[pal.textLight, s.ml5, s.mr5]}>•</Text> + <Text style={pal.textLight}> · </Text> <Link href={translatorUrl} title={_(msg`Translate`)}> <Text style={pal.link}> <Trans>Translate</Trans> 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/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 8dee4ed49..a8aff0510 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -205,7 +205,7 @@ let FeedItemInner = ({ title={_( msg`Reposted by ${sanitizeDisplayName( reason.by.displayName || reason.by.handle, - )})`, + )}`, )}> <FontAwesomeIcon icon="retweet" diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx index 397588cfb..5ec1d0014 100644 --- a/src/view/com/util/ErrorBoundary.tsx +++ b/src/view/com/util/ErrorBoundary.tsx @@ -2,6 +2,7 @@ import React, {Component, ErrorInfo, ReactNode} from 'react' import {ErrorScreen} from './error/ErrorScreen' import {CenteredView} from './Views' import {t} from '@lingui/macro' +import {logger} from '#/logger' interface Props { children?: ReactNode @@ -23,7 +24,7 @@ export class ErrorBoundary extends Component<Props, State> { } public componentDidCatch(error: Error, errorInfo: ErrorInfo) { - console.error('Uncaught error:', error, errorInfo) + logger.error(error, {errorInfo}) } public render() { diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index dcbec7cb4..db26258d6 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(() => { @@ -301,6 +306,8 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ ) }) +const EXEMPT_PATHS = ['/robots.txt', '/security.txt', '/.well-known/'] + // NOTE // we can't use the onPress given by useLinkProps because it will // match most paths to the HomeTab routes while we actually want to @@ -317,6 +324,7 @@ function onPressInner( navigation: NavigationProp, href: string, navigationAction: 'push' | 'replace' | 'navigate' = 'push', + openLink: (href: string) => void, e?: Event, ) { let shouldHandle = false @@ -344,8 +352,13 @@ function onPressInner( if (shouldHandle) { href = convertBskyAppUrlIfNeeded(href) - if (newTab || href.startsWith('http') || href.startsWith('mailto')) { - Linking.openURL(href) + if ( + newTab || + href.startsWith('http') || + href.startsWith('mailto') || + EXEMPT_PATHS.some(path => href.startsWith(path)) + ) { + openLink(href) } else { closeModal() // close any active modals diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index 9abd7d35a..d30a9d805 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -1,4 +1,4 @@ -import React, {memo, startTransition} from 'react' +import React, {memo} from 'react' import {FlatListProps, RefreshControl} from 'react-native' import {FlatList_INTERNAL} from './Views' import {addStyle} from 'lib/styles' @@ -39,9 +39,7 @@ function ListImpl<ItemT>( const pal = usePalette('default') function handleScrolledDownChange(didScrollDown: boolean) { - startTransition(() => { - onScrolledDownChange?.(didScrollDown) - }) + onScrolledDownChange?.(didScrollDown) } const scrollHandler = useAnimatedScrollHandler({ diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx new file mode 100644 index 000000000..3e81a8c37 --- /dev/null +++ b/src/view/com/util/List.web.tsx @@ -0,0 +1,341 @@ +import React, {isValidElement, memo, useRef, startTransition} from 'react' +import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native' +import {addStyle} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {useScrollHandlers} from '#/lib/ScrollContext' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {batchedUpdates} from '#/lib/batchedUpdates' + +export type ListMethods = any // TODO: Better types. +export type ListProps<ItemT> = Omit< + FlatListProps<ItemT>, + | 'onScroll' // Use ScrollContext instead. + | 'refreshControl' // Pass refreshing and/or onRefresh instead. + | 'contentOffset' // Pass headerOffset instead. +> & { + onScrolledDownChange?: (isScrolledDown: boolean) => void + headerOffset?: number + refreshing?: boolean + onRefresh?: () => void + desktopFixedHeight: any // TODO: Better types. +} +export type ListRef = React.MutableRefObject<any | null> // TODO: Better types. + +function ListImpl<ItemT>( + { + ListHeaderComponent, + ListFooterComponent, + contentContainerStyle, + data, + desktopFixedHeight, + headerOffset, + keyExtractor, + refreshing: _unsupportedRefreshing, + onEndReached, + onEndReachedThreshold = 0, + onRefresh: _unsupportedOnRefresh, + onScrolledDownChange, + onContentSizeChange, + renderItem, + extraData, + style, + ...props + }: ListProps<ItemT>, + ref: React.Ref<ListMethods>, +) { + const contextScrollHandlers = useScrollHandlers() + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + if (!isMobile) { + contentContainerStyle = addStyle( + contentContainerStyle, + styles.containerScroll, + ) + } + + let header: JSX.Element | null = null + if (ListHeaderComponent != null) { + if (isValidElement(ListHeaderComponent)) { + header = ListHeaderComponent + } else { + // @ts-ignore Nah it's fine. + header = <ListHeaderComponent /> + } + } + + let footer: JSX.Element | null = null + if (ListFooterComponent != null) { + if (isValidElement(ListFooterComponent)) { + footer = ListFooterComponent + } else { + // @ts-ignore Nah it's fine. + footer = <ListFooterComponent /> + } + } + + if (headerOffset != null) { + style = addStyle(style, { + paddingTop: headerOffset, + }) + } + + const nativeRef = React.useRef(null) + React.useImperativeHandle( + ref, + () => + ({ + scrollToTop() { + window.scrollTo({top: 0}) + }, + scrollToOffset({ + animated, + offset, + }: { + animated: boolean + offset: number + }) { + window.scrollTo({ + left: 0, + top: offset, + behavior: animated ? 'smooth' : 'instant', + }) + }, + } as any), // TODO: Better types. + [], + ) + + // --- onContentSizeChange --- + const containerRef = useRef(null) + useResizeObserver(containerRef, onContentSizeChange) + + // --- onScroll --- + const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false) + const handleWindowScroll = useNonReactiveCallback(() => { + if (isInsideVisibleTree) { + contextScrollHandlers.onScroll?.( + { + contentOffset: { + x: Math.max(0, window.scrollX), + y: Math.max(0, window.scrollY), + }, + } as any, // TODO: Better types. + null as any, + ) + } + }) + React.useEffect(() => { + if (!isInsideVisibleTree) { + // Prevents hidden tabs from firing scroll events. + // Only one list is expected to be firing these at a time. + return + } + window.addEventListener('scroll', handleWindowScroll) + return () => { + window.removeEventListener('scroll', handleWindowScroll) + } + }, [isInsideVisibleTree, handleWindowScroll]) + + // --- onScrolledDownChange --- + const isScrolledDown = useRef(false) + function handleAboveTheFoldVisibleChange(isAboveTheFold: boolean) { + const didScrollDown = !isAboveTheFold + if (isScrolledDown.current !== didScrollDown) { + isScrolledDown.current = didScrollDown + startTransition(() => { + onScrolledDownChange?.(didScrollDown) + }) + } + } + + // --- onEndReached --- + const onTailVisibilityChange = useNonReactiveCallback( + (isTailVisible: boolean) => { + if (isTailVisible) { + onEndReached?.({ + distanceFromEnd: onEndReachedThreshold || 0, + }) + } + }, + ) + + return ( + <View {...props} style={style} ref={nativeRef}> + <Visibility + onVisibleChange={setIsInsideVisibleTree} + style={ + // This has position: fixed, so it should always report as visible + // unless we're within a display: none tree (like a hidden tab). + styles.parentTreeVisibilityDetector + } + /> + <View + ref={containerRef} + style={[ + styles.contentContainer, + contentContainerStyle, + desktopFixedHeight ? styles.minHeightViewport : null, + pal.border, + ]}> + <Visibility + onVisibleChange={handleAboveTheFoldVisibleChange} + style={[styles.aboveTheFoldDetector, {height: headerOffset}]} + /> + {header} + {(data as Array<ItemT>).map((item, index) => ( + <Row<ItemT> + key={keyExtractor!(item, index)} + item={item} + index={index} + renderItem={renderItem} + extraData={extraData} + /> + ))} + {onEndReached && ( + <Visibility + topMargin={(onEndReachedThreshold ?? 0) * 100 + '%'} + onVisibleChange={onTailVisibilityChange} + /> + )} + {footer} + </View> + </View> + ) +} + +function useResizeObserver( + ref: React.RefObject<Element>, + onResize: undefined | ((w: number, h: number) => void), +) { + const handleResize = useNonReactiveCallback(onResize ?? (() => {})) + const isActive = !!onResize + React.useEffect(() => { + if (!isActive) { + return + } + const resizeObserver = new ResizeObserver(entries => { + batchedUpdates(() => { + for (let entry of entries) { + const rect = entry.contentRect + handleResize(rect.width, rect.height) + } + }) + }) + const node = ref.current! + resizeObserver.observe(node) + return () => { + resizeObserver.unobserve(node) + } + }, [handleResize, isActive, ref]) +} + +let Row = function RowImpl<ItemT>({ + item, + index, + renderItem, + extraData: _unused, +}: { + item: ItemT + index: number + renderItem: + | null + | undefined + | ((data: {index: number; item: any; separators: any}) => React.ReactNode) + extraData: any +}): React.ReactNode { + if (!renderItem) { + return null + } + return ( + <View style={styles.row}> + {renderItem({item, index, separators: null as any})} + </View> + ) +} +Row = React.memo(Row) + +let Visibility = ({ + topMargin = '0px', + onVisibleChange, + style, +}: { + topMargin?: string + onVisibleChange: (isVisible: boolean) => void + style?: ViewProps['style'] +}): React.ReactNode => { + const tailRef = React.useRef(null) + const isIntersecting = React.useRef(false) + + const handleIntersection = useNonReactiveCallback( + (entries: IntersectionObserverEntry[]) => { + batchedUpdates(() => { + entries.forEach(entry => { + if (entry.isIntersecting !== isIntersecting.current) { + isIntersecting.current = entry.isIntersecting + onVisibleChange(entry.isIntersecting) + } + }) + }) + }, + ) + + React.useEffect(() => { + const observer = new IntersectionObserver(handleIntersection, { + rootMargin: `${topMargin} 0px 0px 0px`, + }) + const tail: Element | null = tailRef.current! + observer.observe(tail) + return () => { + observer.unobserve(tail) + } + }, [handleIntersection, topMargin]) + + return ( + <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} /> + ) +} +Visibility = React.memo(Visibility) + +export const List = memo(React.forwardRef(ListImpl)) as <ItemT>( + props: ListProps<ItemT> & {ref?: React.Ref<ListMethods>}, +) => React.ReactElement + +const styles = StyleSheet.create({ + contentContainer: { + borderLeftWidth: 1, + borderRightWidth: 1, + }, + containerScroll: { + width: '100%', + maxWidth: 600, + marginLeft: 'auto', + marginRight: 'auto', + }, + row: { + // @ts-ignore web only + contentVisibility: 'auto', + }, + minHeightViewport: { + // @ts-ignore web only + minHeight: '100vh', + }, + parentTreeVisibilityDetector: { + // @ts-ignore web only + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + aboveTheFoldDetector: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + // Bottom is dynamic. + }, + visibilityDetector: { + pointerEvents: 'none', + zIndex: -1, + }, +}) diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index 31a4ef0c8..2c90e33ff 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -1,11 +1,14 @@ -import React, {useCallback} from 'react' +import React, {useCallback, useEffect} from 'react' +import EventEmitter from 'eventemitter3' import {ScrollProvider} from '#/lib/ScrollContext' import {NativeScrollEvent} from 'react-native' import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell' import {useShellLayout} from '#/state/shell/shell-layout' -import {isWeb} from 'platform/detection' +import {isNative, isWeb} from 'platform/detection' import {useSharedValue, interpolate} from 'react-native-reanimated' +const WEB_HIDE_SHELL_THRESHOLD = 200 + function clamp(num: number, min: number, max: number) { 'worklet' return Math.min(Math.max(num, min), max) @@ -18,11 +21,22 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { const startDragOffset = useSharedValue<number | null>(null) const startMode = useSharedValue<number | null>(null) + useEffect(() => { + if (isWeb) { + return listenToForcedWindowScroll(() => { + startDragOffset.value = null + startMode.value = null + }) + } + }) + const onBeginDrag = useCallback( (e: NativeScrollEvent) => { 'worklet' - startDragOffset.value = e.contentOffset.y - startMode.value = mode.value + if (isNative) { + startDragOffset.value = e.contentOffset.y + startMode.value = mode.value + } }, [mode, startDragOffset, startMode], ) @@ -30,14 +44,16 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { const onEndDrag = useCallback( (e: NativeScrollEvent) => { 'worklet' - startDragOffset.value = null - startMode.value = null - if (e.contentOffset.y < headerHeight.value / 2) { - // If we're close to the top, show the shell. - setMode(false) - } else { - // Snap to whichever state is the closest. - setMode(Math.round(mode.value) === 1) + if (isNative) { + startDragOffset.value = null + startMode.value = null + if (e.contentOffset.y < headerHeight.value / 2) { + // If we're close to the top, show the shell. + setMode(false) + } else { + // Snap to whichever state is the closest. + setMode(Math.round(mode.value) === 1) + } } }, [startDragOffset, startMode, setMode, mode, headerHeight], @@ -46,41 +62,40 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { const onScroll = useCallback( (e: NativeScrollEvent) => { 'worklet' - if (startDragOffset.value === null || startMode.value === null) { - if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) { - // If we're close enough to the top, always show the shell. - // Even if we're not dragging. - setMode(false) + if (isNative) { + if (startDragOffset.value === null || startMode.value === null) { + if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) { + // If we're close enough to the top, always show the shell. + // Even if we're not dragging. + setMode(false) + } return } - if (isWeb) { - // On the web, there is no concept of "starting" the drag. - // When we get the first scroll event, we consider that the start. - startDragOffset.value = e.contentOffset.y - startMode.value = mode.value - } - return - } - // The "mode" value is always between 0 and 1. - // Figure out how much to move it based on the current dragged distance. - const dy = e.contentOffset.y - startDragOffset.value - const dProgress = interpolate( - dy, - [-headerHeight.value, headerHeight.value], - [-1, 1], - ) - const newValue = clamp(startMode.value + dProgress, 0, 1) - if (newValue !== mode.value) { - // Manually adjust the value. This won't be (and shouldn't be) animated. - mode.value = newValue - } - if (isWeb) { - // On the web, there is no concept of "starting" the drag, - // so we don't have any specific anchor point to calculate the distance. - // Instead, update it continuosly along the way and diff with the last event. + // The "mode" value is always between 0 and 1. + // Figure out how much to move it based on the current dragged distance. + const dy = e.contentOffset.y - startDragOffset.value + const dProgress = interpolate( + dy, + [-headerHeight.value, headerHeight.value], + [-1, 1], + ) + const newValue = clamp(startMode.value + dProgress, 0, 1) + if (newValue !== mode.value) { + // Manually adjust the value. This won't be (and shouldn't be) animated. + mode.value = newValue + } + } else { + // On the web, we don't try to follow the drag because we don't know when it ends. + // Instead, show/hide immediately based on whether we're scrolling up or down. + const dy = e.contentOffset.y - (startDragOffset.value ?? 0) startDragOffset.value = e.contentOffset.y - startMode.value = mode.value + + if (dy < 0 || e.contentOffset.y < WEB_HIDE_SHELL_THRESHOLD) { + setMode(false) + } else if (dy > 0) { + setMode(true) + } } }, [headerHeight, mode, setMode, startDragOffset, startMode], @@ -95,3 +110,26 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { </ScrollProvider> ) } + +const emitter = new EventEmitter() + +if (isWeb) { + const originalScroll = window.scroll + window.scroll = function () { + emitter.emit('forced-scroll') + return originalScroll.apply(this, arguments as any) + } + + const originalScrollTo = window.scrollTo + window.scrollTo = function () { + emitter.emit('forced-scroll') + return originalScrollTo.apply(this, arguments as any) + } +} + +function listenToForcedWindowScroll(listener: () => void) { + emitter.addListener('forced-scroll', listener) + return () => { + emitter.removeListener('forced-scroll', listener) + } +} diff --git a/src/view/com/util/SimpleViewHeader.tsx b/src/view/com/util/SimpleViewHeader.tsx index e86e37565..814b2fb15 100644 --- a/src/view/com/util/SimpleViewHeader.tsx +++ b/src/view/com/util/SimpleViewHeader.tsx @@ -14,6 +14,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useAnalytics} from 'lib/analytics/analytics' import {NavigationProp} from 'lib/routes/types' import {useSetDrawerOpen} from '#/state/shell' +import {isWeb} from '#/platform/detection' const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} @@ -47,7 +48,14 @@ export function SimpleViewHeader({ const Container = isMobile ? View : CenteredView return ( - <Container style={[styles.header, isMobile && styles.headerMobile, style]}> + <Container + style={[ + styles.header, + isMobile && styles.headerMobile, + isWeb && styles.headerWeb, + pal.view, + style, + ]}> {showBackButton ? ( <TouchableOpacity testID="viewHeaderDrawerBtn" @@ -89,6 +97,12 @@ const styles = StyleSheet.create({ paddingHorizontal: 12, paddingVertical: 10, }, + headerWeb: { + // @ts-ignore web-only + position: 'sticky', + top: 0, + zIndex: 1, + }, backBtn: { width: 30, height: 30, diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx index beb67c30c..d5a843541 100644 --- a/src/view/com/util/Toast.web.tsx +++ b/src/view/com/util/Toast.web.tsx @@ -64,7 +64,8 @@ export function show(text: string, icon: FontAwesomeProps['icon'] = 'check') { const styles = StyleSheet.create({ container: { - position: 'absolute', + // @ts-ignore web only + position: 'fixed', left: 20, bottom: 20, // @ts-ignore web only diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx index 9787d92fb..27a16117b 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -6,6 +6,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {clamp} from 'lib/numbers' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import {isWeb} from '#/platform/detection' import Animated from 'react-native-reanimated' export interface FABProps @@ -64,7 +65,8 @@ const styles = StyleSheet.create({ borderRadius: 35, }, outer: { - position: 'absolute', + // @ts-ignore web-only + position: isWeb ? 'fixed' : 'absolute', zIndex: 1, }, inner: { diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 8e31c9e63..b21caf2e7 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -104,7 +104,7 @@ let PostDropdownBtn = ({ }, [rootUri, toggleThreadMute, _]) const onCopyPostText = React.useCallback(() => { - const str = richTextToString(richText) + const str = richTextToString(richText, true) Clipboard.setString(str) Toast.show(_(msg`Copied to clipboard`)) diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx index 249c556ec..b3a563116 100644 --- a/src/view/com/util/moderation/ContentHider.tsx +++ b/src/view/com/util/moderation/ContentHider.tsx @@ -94,7 +94,7 @@ export function ContentHider({ <ShieldExclamation size={18} style={pal.textLight} /> )} </Pressable> - <Text type="md" style={pal.text}> + <Text type="md" style={[pal.text, {flex: 1}]} numberOfLines={2}> {desc.name} </Text> <View style={styles.showBtn}> @@ -131,7 +131,7 @@ const styles = StyleSheet.create({ cover: { flexDirection: 'row', alignItems: 'center', - gap: 4, + gap: 6, borderRadius: 8, marginTop: 4, paddingVertical: 14, diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index af62aa2b3..aaa98a41f 100644 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -29,22 +29,13 @@ export const ExternalLinkEmbed = ({ }, [link.uri, externalEmbedPrefs]) return ( - <View style={{flexDirection: 'column'}}> + <View style={styles.container}> {link.thumb && !embedPlayerParams ? ( - <View - style={{ - borderTopLeftRadius: 6, - borderTopRightRadius: 6, - width: '100%', - height: isMobile ? 200 : 300, - overflow: 'hidden', - }}> - <Image - style={styles.extImage} - source={{uri: link.thumb}} - accessibilityIgnoresInvertColors - /> - </View> + <Image + style={{aspectRatio: 1.91}} + source={{uri: link.thumb}} + accessibilityIgnoresInvertColors + /> ) : undefined} {(embedPlayerParams?.isGif && ( <ExternalGifEmbed link={link} params={embedPlayerParams} /> @@ -52,12 +43,7 @@ export const ExternalLinkEmbed = ({ (embedPlayerParams && ( <ExternalPlayer link={link} params={embedPlayerParams} /> ))} - <View - style={{ - paddingHorizontal: isMobile ? 10 : 14, - paddingTop: 8, - paddingBottom: 10, - }}> + <View style={[styles.info, {paddingHorizontal: isMobile ? 10 : 14}]}> <Text type="sm" numberOfLines={1} @@ -65,14 +51,14 @@ export const ExternalLinkEmbed = ({ {toNiceDomain(link.uri)} </Text> {!embedPlayerParams?.isGif && ( - <Text type="lg-bold" numberOfLines={4} style={[pal.text]}> + <Text type="lg-bold" numberOfLines={3} style={[pal.text]}> {link.title || link.uri} </Text> )} {link.description && !embedPlayerParams?.hideDetails ? ( <Text type="md" - numberOfLines={4} + numberOfLines={link.thumb ? 2 : 4} style={[pal.text, styles.extDescription]}> {link.description} </Text> @@ -83,8 +69,16 @@ export const ExternalLinkEmbed = ({ } const styles = StyleSheet.create({ - extImage: { - flex: 1, + container: { + flexDirection: 'column', + borderRadius: 6, + overflow: 'hidden', + }, + info: { + width: '100%', + bottom: 0, + paddingTop: 8, + paddingBottom: 10, }, extUri: { marginTop: 2, diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index e5fe44c4d..256817bba 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -7,6 +7,7 @@ import { AppBskyEmbedRecordWithMedia, ModerationUI, AppBskyEmbedExternal, + RichText as RichTextAPI, } from '@atproto/api' import {AtUri} from '@atproto/api' import {PostMeta} from '../PostMeta' @@ -19,6 +20,7 @@ import {PostAlerts} from '../moderation/PostAlerts' import {makeProfileLink} from 'lib/routes/links' import {InfoCircleIcon} from 'lib/icons' import {Trans} from '@lingui/macro' +import {RichText} from 'view/com/util/text/RichText' export function MaybeQuoteEmbed({ embed, @@ -43,6 +45,7 @@ export function MaybeQuoteEmbed({ uri: embed.record.uri, indexedAt: embed.record.indexedAt, text: embed.record.value.text, + facets: embed.record.value.facets, embeds: embed.record.embeds, }} moderation={moderation} @@ -84,9 +87,12 @@ export function QuoteEmbed({ const itemUrip = new AtUri(quote.uri) const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey) const itemTitle = `Post by ${quote.author.handle}` - const isEmpty = React.useMemo( - () => quote.text.trim().length === 0, - [quote.text], + const richText = React.useMemo( + () => + quote.text.trim() + ? new RichTextAPI({text: quote.text, facets: quote.facets}) + : undefined, + [quote.text, quote.facets], ) const embed = React.useMemo(() => { const e = quote.embeds?.[0] @@ -117,10 +123,14 @@ export function QuoteEmbed({ {moderation ? ( <PostAlerts moderation={moderation} style={styles.alert} /> ) : null} - {!isEmpty ? ( - <Text type="post-text" style={pal.text} numberOfLines={20}> - {quote.text} - </Text> + {richText ? ( + <RichText + richText={richText} + type="post-text" + style={pal.text} + numberOfLines={20} + noLinks + /> ) : null} {embed && <PostEmbeds embed={embed} moderation={{}} />} </Link> 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, diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index 99062e848..e910127fe 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -17,6 +17,8 @@ export function RichText({ lineHeight = 1.2, style, numberOfLines, + selectable, + noLinks, }: { testID?: string type?: TypographyVariant @@ -24,6 +26,8 @@ export function RichText({ lineHeight?: number style?: StyleProp<TextStyle> numberOfLines?: number + selectable?: boolean + noLinks?: boolean }) { const theme = useTheme() const pal = usePalette('default') @@ -42,7 +46,11 @@ export function RichText({ } return ( // @ts-ignore web only -prf - <Text testID={testID} style={[style, pal.text]} dataSet={WORD_WRAP}> + <Text + testID={testID} + style={[style, pal.text]} + dataSet={WORD_WRAP} + selectable={selectable}> {text} </Text> ) @@ -54,7 +62,8 @@ export function RichText({ style={[style, pal.text, lineHeightStyle]} numberOfLines={numberOfLines} // @ts-ignore web only -prf - dataSet={WORD_WRAP}> + dataSet={WORD_WRAP} + selectable={selectable}> {text} </Text> ) @@ -70,7 +79,11 @@ export function RichText({ for (const segment of richText.segments()) { const link = segment.link const mention = segment.mention - if (mention && AppBskyRichtextFacet.validateMention(mention).success) { + if ( + !noLinks && + mention && + AppBskyRichtextFacet.validateMention(mention).success + ) { els.push( <TextLink key={key} @@ -79,20 +92,26 @@ export function RichText({ href={`/profile/${mention.did}`} style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} dataSet={WORD_WRAP} + selectable={selectable} />, ) } else if (link && AppBskyRichtextFacet.validateLink(link).success) { - els.push( - <TextLink - key={key} - type={type} - text={toShortUrl(segment.text)} - href={link.uri} - style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} - dataSet={WORD_WRAP} - warnOnMismatchingLabel - />, - ) + if (noLinks) { + els.push(toShortUrl(segment.text)) + } else { + els.push( + <TextLink + key={key} + type={type} + text={toShortUrl(segment.text)} + href={link.uri} + style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} + dataSet={WORD_WRAP} + warnOnMismatchingLabel + selectable={selectable} + />, + ) + } } else { els.push(segment.text) } @@ -105,7 +124,8 @@ export function RichText({ style={[style, pal.text, lineHeightStyle]} numberOfLines={numberOfLines} // @ts-ignore web only -prf - dataSet={WORD_WRAP}> + dataSet={WORD_WRAP} + selectable={selectable}> {els} </Text> ) diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx index ea97d59fe..ccb51bfca 100644 --- a/src/view/com/util/text/Text.tsx +++ b/src/view/com/util/text/Text.tsx @@ -2,12 +2,15 @@ import React from 'react' import {Text as RNText, TextProps} from 'react-native' import {s, lh} from 'lib/styles' import {useTheme, TypographyVariant} from 'lib/ThemeContext' +import {isIOS} from 'platform/detection' +import {UITextView} from 'react-native-ui-text-view' export type CustomTextProps = TextProps & { type?: TypographyVariant lineHeight?: number title?: string dataSet?: Record<string, string | number> + selectable?: boolean } export function Text({ @@ -17,16 +20,29 @@ export function Text({ style, title, dataSet, + selectable, ...props }: React.PropsWithChildren<CustomTextProps>) { const theme = useTheme() const typography = theme.typography[type] const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined + + if (selectable && isIOS) { + return ( + <UITextView + style={[s.black, typography, lineHeightStyle, style]} + {...props}> + {children} + </UITextView> + ) + } + return ( <RNText style={[s.black, typography, lineHeightStyle, style]} // @ts-ignore web only -esb dataSet={Object.assign({tooltip: title}, dataSet || {})} + selectable={selectable} {...props}> {children} </RNText> |