diff options
Diffstat (limited to 'src/view')
171 files changed, 6107 insertions, 1584 deletions
diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index c0427ff54..603abbab2 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -2,7 +2,7 @@ import React from 'react' import {View, Pressable} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' +import {Trans, msg} from '@lingui/macro' import {useNavigation} from '@react-navigation/native' import {isIOS, isNative} from 'platform/detection' @@ -119,7 +119,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { }} onPress={onPressSearch}> <Text type="lg-bold" style={[pal.text]}> - Search{' '} + <Trans>Search</Trans>{' '} </Text> <FontAwesomeIcon icon="search" diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx index 1cc7b9146..d2b1a47e3 100644 --- a/src/view/com/auth/SplashScreen.web.tsx +++ b/src/view/com/auth/SplashScreen.web.tsx @@ -74,7 +74,7 @@ export const SplashScreen = ({ // TODO: web accessibility accessibilityRole="button"> <Text style={[s.white, styles.btnLabel]}> - Create a new account + <Trans>Create a new account</Trans> </Text> </TouchableOpacity> <TouchableOpacity diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index a89e6fb34..449afb0d3 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -1,7 +1,6 @@ import React from 'react' import { ActivityIndicator, - KeyboardAvoidingView, ScrollView, StyleSheet, TouchableOpacity, @@ -23,11 +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() @@ -38,6 +39,7 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) { const {createAccount} = useSessionApi() const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation() const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() + const {isTabletOrDesktop} = useWebMediaQueries() React.useEffect(() => { screen('CreateAccount') @@ -116,68 +118,87 @@ 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}> - <KeyboardAvoidingView behavior="padding"> - <View style={styles.stepContainer}> - {uiState.step === 1 && ( - <Step1 uiState={uiState} uiDispatch={uiDispatch} /> - )} - {uiState.step === 2 && ( - <Step2 uiState={uiState} uiDispatch={uiDispatch} /> - )} - {uiState.step === 3 && ( - <Step3 uiState={uiState} uiDispatch={uiDispatch} /> - )} - </View> - <View style={[s.flexRow, s.pl20, s.pr20]}> + <View style={styles.stepContainer}> + {uiState.step === 1 && ( + <Step1 uiState={uiState} uiDispatch={uiDispatch} /> + )} + {uiState.step === 2 && ( + <Step2 uiState={uiState} uiDispatch={uiDispatch} /> + )} + {uiState.step === 3 && ( + <Step3 uiState={uiState} uiDispatch={uiDispatch} /> + )} + </View> + <View style={[s.flexRow, s.pl20, s.pr20]}> + <TouchableOpacity + onPress={onPressBackInner} + testID="backBtn" + accessibilityRole="button"> + <Text type="xl" style={pal.link}> + <Trans>Back</Trans> + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {uiState.canNext ? ( <TouchableOpacity - onPress={onPressBackInner} - testID="backBtn" + testID="nextBtn" + onPress={onPressNext} accessibilityRole="button"> - <Text type="xl" style={pal.link}> - <Trans>Back</Trans> - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {uiState.canNext ? ( - <TouchableOpacity - testID="nextBtn" - onPress={onPressNext} - accessibilityRole="button"> - {uiState.isProcessing ? ( - <ActivityIndicator /> - ) : ( - <Text type="xl-bold" style={[pal.link, s.pr5]}> - <Trans>Next</Trans> - </Text> - )} - </TouchableOpacity> - ) : serviceInfoError ? ( - <TouchableOpacity - testID="retryConnectBtn" - onPress={() => refetchServiceInfo()} - accessibilityRole="button" - accessibilityLabel={_(msg`Retry`)} - accessibilityHint="" - accessibilityLiveRegion="polite"> + {uiState.isProcessing ? ( + <ActivityIndicator /> + ) : ( <Text type="xl-bold" style={[pal.link, s.pr5]}> - <Trans>Retry</Trans> + <Trans>Next</Trans> </Text> - </TouchableOpacity> - ) : serviceInfoIsFetching ? ( - <> - <ActivityIndicator color="#fff" /> - <Text type="xl" style={[pal.text, s.pr5]}> - <Trans>Connecting...</Trans> - </Text> - </> - ) : undefined} + )} + </TouchableOpacity> + ) : serviceInfoError ? ( + <TouchableOpacity + testID="retryConnectBtn" + onPress={() => refetchServiceInfo()} + accessibilityRole="button" + accessibilityLabel={_(msg`Retry`)} + accessibilityHint="" + accessibilityLiveRegion="polite"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + <Trans>Retry</Trans> + </Text> + </TouchableOpacity> + ) : serviceInfoIsFetching ? ( + <> + <ActivityIndicator color="#fff" /> + <Text type="xl" style={[pal.text, s.pr5]}> + <Trans>Connecting...</Trans> + </Text> + </> + ) : 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 style={s.footerSpacer} /> - </KeyboardAvoidingView> + </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 c9d19e868..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,135 +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="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 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={`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> ) } @@ -164,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 89fd070ad..f6eedc2eb 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -1,28 +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 {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' -/** 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, @@ -32,116 +38,253 @@ export function Step2({ }) { const pal = usePalette('default') const {_} = useLingui() - const {openModal} = useModalControls() + const {isMobile} = useWebMediaQueries() - const onPressWaitlist = React.useCallback(() => { - openModal({name: 'waitlist'}) - }, [openModal]) + const onPressRequest = React.useCallback(() => { + if ( + uiState.verificationPhone.length >= 9 && + parsePhoneNumber(uiState.verificationPhone, 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 onPressRetry = React.useCallback(() => { + uiDispatch({type: 'set-has-requested-verification-code', value: false}) + }, [uiDispatch]) + + 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]}> - Invite code - </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="Input invite code to proceed" - /> - </View> - )} + <StepHeader uiState={uiState} title={_(msg`SMS verification`)} /> - {!uiState.inviteCode && uiState.isInviteCodeRequired ? ( - <Text style={[s.alignBaseline, pal.text]}> - Don't have an invite code?{' '} - <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="Input email for Bluesky waitlist" - accessibilityLabelledBy="email" - /> + <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="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="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 - testID="birthdayInput" - value={uiState.birthDate} - onChange={value => uiDispatch({type: 'set-birth-date', value})} - buttonType="default-light" - buttonStyle={[pal.border, styles.dateInputButton]} - buttonLabelType="lg" - accessibilityLabel={_(msg`Birthday`)} - accessibilityHint="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} @@ -154,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 4c8a58519..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" @@ -36,7 +36,7 @@ export function Step3({ onChange={value => uiDispatch({type: 'set-handle', value})} // TODO: Add explicit text label accessibilityLabel={_(msg`User handle`)} - accessibilityHint="Input your user handle" + accessibilityHint={_(msg`Input your user handle`)} /> <Text type="lg" style={[pal.text, s.pl5, s.pt10]}> <Trans>Your full handle will be</Trans>{' '} diff --git a/src/view/com/auth/create/StepHeader.tsx b/src/view/com/auth/create/StepHeader.tsx index 4b4eb5d23..af6bf5478 100644 --- a/src/view/com/auth/create/StepHeader.tsx +++ b/src/view/com/auth/create/StepHeader.tsx @@ -2,23 +2,43 @@ import React from 'react' 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' ? 'Last step!' : <>Step {step} of 3</>} - </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 a77d2a44f..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,8 +198,17 @@ 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)) { + logger.warn('Failed to create account', {error: e}) + } else { + logger.error(`Failed to create account (${e.status} status)`, {error: e}) } - logger.error('Failed to create account', {error: e}) + uiDispatch({type: 'set-processing', value: false}) uiDispatch({type: 'set-error', value: cleanError(errMsg)}) throw e @@ -195,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}) } @@ -202,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, @@ -212,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}) } } } @@ -224,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 } @@ -238,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/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx index 73ddfc9d6..32cd8315d 100644 --- a/src/view/com/auth/login/ChooseAccountForm.tsx +++ b/src/view/com/auth/login/ChooseAccountForm.tsx @@ -42,7 +42,7 @@ function AccountItem({ onPress={onPress} accessibilityRole="button" accessibilityLabel={_(msg`Sign in as ${account.handle}`)} - accessibilityHint="Double tap to sign in"> + accessibilityHint={_(msg`Double tap to sign in`)}> <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> <View style={s.p10}> <UserAvatar avatar={profile?.avatar} size={30} /> @@ -95,19 +95,19 @@ export const ChooseAccountForm = ({ if (account.accessJwt) { if (account.did === currentAccount?.did) { setShowLoggedOut(false) - Toast.show(`Already signed in as @${account.handle}`) + Toast.show(_(msg`Already signed in as @${account.handle}`)) } else { await initSession(account) track('Sign In', {resumedSession: true}) setTimeout(() => { - Toast.show(`Signed in as @${account.handle}`) + Toast.show(_(msg`Signed in as @${account.handle}`)) }, 100) } } else { onSelectAccount(account) } }, - [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut], + [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _], ) return ( diff --git a/src/view/com/auth/login/ForgotPasswordForm.tsx b/src/view/com/auth/login/ForgotPasswordForm.tsx index 215c393d9..f9bb64f98 100644 --- a/src/view/com/auth/login/ForgotPasswordForm.tsx +++ b/src/view/com/auth/login/ForgotPasswordForm.tsx @@ -67,7 +67,7 @@ export const ForgotPasswordForm = ({ const onPressNext = async () => { if (!EmailValidator.validate(email)) { - return setError('Your email appears to be invalid.') + return setError(_(msg`Your email appears to be invalid.`)) } setError('') @@ -83,7 +83,9 @@ export const ForgotPasswordForm = ({ setIsProcessing(false) if (isNetworkError(e)) { setError( - 'Unable to contact your service. Please check your Internet connection.', + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), ) } else { setError(cleanError(errMsg)) @@ -112,7 +114,9 @@ export const ForgotPasswordForm = ({ onPress={onPressSelectService} accessibilityRole="button" accessibilityLabel={_(msg`Hosting provider`)} - accessibilityHint="Sets hosting provider for password reset"> + accessibilityHint={_( + msg`Sets hosting provider for password reset`, + )}> <FontAwesomeIcon icon="globe" style={[pal.textLight, styles.groupContentIcon]} @@ -136,7 +140,7 @@ export const ForgotPasswordForm = ({ <TextInput testID="forgotPasswordEmail" style={[pal.text, styles.textInput]} - placeholder="Email address" + placeholder={_(msg`Email address`)} placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoFocus @@ -146,7 +150,7 @@ export const ForgotPasswordForm = ({ onChangeText={setEmail} editable={!isProcessing} accessibilityLabel={_(msg`Email`)} - accessibilityHint="Sets email for password reset" + accessibilityHint={_(msg`Sets email for password reset`)} /> </View> </View> @@ -179,7 +183,7 @@ export const ForgotPasswordForm = ({ onPress={onPressNext} accessibilityRole="button" accessibilityLabel={_(msg`Go to next`)} - accessibilityHint="Navigates to the next screen"> + accessibilityHint={_(msg`Navigates to the next screen`)}> <Text type="xl-bold" style={[pal.link, s.pr5]}> <Trans>Next</Trans> </Text> diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx index a39d0d9bf..10608a54b 100644 --- a/src/view/com/auth/login/LoginForm.tsx +++ b/src/view/com/auth/login/LoginForm.tsx @@ -107,17 +107,21 @@ export const LoginForm = ({ }) } catch (e: any) { const errMsg = e.toString() - logger.warn('Failed to login', {error: e}) setIsProcessing(false) if (errMsg.includes('Authentication Required')) { + logger.info('Failed to login due to invalid credentials', { + error: errMsg, + }) setError(_(msg`Invalid username or password`)) } else if (isNetworkError(e)) { + logger.warn('Failed to login due to network error', {error: errMsg}) setError( _( msg`Unable to contact your service. Please check your Internet connection.`, ), ) } else { + logger.warn('Failed to login', {error: errMsg}) setError(cleanError(errMsg)) } } @@ -141,7 +145,7 @@ export const LoginForm = ({ onPress={onPressSelectService} accessibilityRole="button" accessibilityLabel={_(msg`Select service`)} - accessibilityHint="Sets server for the Bluesky client"> + accessibilityHint={_(msg`Sets server for the Bluesky client`)}> <Text type="xl" style={[pal.text, styles.textBtnLabel]}> {toNiceDomain(serviceUrl)} </Text> @@ -174,6 +178,7 @@ export const LoginForm = ({ autoCorrect={false} autoComplete="username" returnKeyType="next" + textContentType="username" onSubmitEditing={() => { passwordInputRef.current?.focus() }} @@ -185,7 +190,9 @@ export const LoginForm = ({ } editable={!isProcessing} accessibilityLabel={_(msg`Username or email address`)} - accessibilityHint="Input the username or email address you used at signup" + accessibilityHint={_( + msg`Input the username or email address you used at signup`, + )} /> </View> <View style={[pal.borderDark, styles.groupContent]}> @@ -216,8 +223,8 @@ export const LoginForm = ({ accessibilityLabel={_(msg`Password`)} accessibilityHint={ identifier === '' - ? 'Input your password' - : `Input the password tied to ${identifier}` + ? _(msg`Input your password`) + : _(msg`Input the password tied to ${identifier}`) } /> <TouchableOpacity @@ -226,7 +233,7 @@ export const LoginForm = ({ onPress={onPressForgotPassword} accessibilityRole="button" accessibilityLabel={_(msg`Forgot password`)} - accessibilityHint="Opens password reset form"> + accessibilityHint={_(msg`Opens password reset form`)}> <Text style={pal.link}> <Trans>Forgot</Trans> </Text> @@ -256,7 +263,7 @@ export const LoginForm = ({ onPress={onPressRetryConnect} accessibilityRole="button" accessibilityLabel={_(msg`Retry`)} - accessibilityHint="Retries login"> + accessibilityHint={_(msg`Retries login`)}> <Text type="xl-bold" style={[pal.link, s.pr5]}> <Trans>Retry</Trans> </Text> @@ -276,7 +283,7 @@ export const LoginForm = ({ onPress={onPressNext} accessibilityRole="button" accessibilityLabel={_(msg`Go to next`)} - accessibilityHint="Navigates to the next screen"> + accessibilityHint={_(msg`Navigates to the next screen`)}> <Text type="xl-bold" style={[pal.link, s.pr5]}> <Trans>Next</Trans> </Text> diff --git a/src/view/com/auth/login/PasswordUpdatedForm.tsx b/src/view/com/auth/login/PasswordUpdatedForm.tsx index 1e07588a9..71f750b14 100644 --- a/src/view/com/auth/login/PasswordUpdatedForm.tsx +++ b/src/view/com/auth/login/PasswordUpdatedForm.tsx @@ -36,7 +36,7 @@ export const PasswordUpdatedForm = ({ onPress={onPressNext} accessibilityRole="button" accessibilityLabel={_(msg`Close alert`)} - accessibilityHint="Closes password update alert"> + accessibilityHint={_(msg`Closes password update alert`)}> <Text type="xl-bold" style={[pal.link, s.pr5]}> <Trans>Okay</Trans> </Text> diff --git a/src/view/com/auth/login/SetNewPasswordForm.tsx b/src/view/com/auth/login/SetNewPasswordForm.tsx index 2bb614df2..630c6afde 100644 --- a/src/view/com/auth/login/SetNewPasswordForm.tsx +++ b/src/view/com/auth/login/SetNewPasswordForm.tsx @@ -95,7 +95,7 @@ export const SetNewPasswordForm = ({ <TextInput testID="resetCodeInput" style={[pal.text, styles.textInput]} - placeholder="Reset code" + placeholder={_(msg`Reset code`)} placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoCorrect={false} @@ -106,7 +106,9 @@ export const SetNewPasswordForm = ({ editable={!isProcessing} accessible={true} accessibilityLabel={_(msg`Reset code`)} - accessibilityHint="Input code sent to your email for password reset" + accessibilityHint={_( + msg`Input code sent to your email for password reset`, + )} /> </View> <View style={[pal.borderDark, styles.groupContent]}> @@ -117,7 +119,7 @@ export const SetNewPasswordForm = ({ <TextInput testID="newPasswordInput" style={[pal.text, styles.textInput]} - placeholder="New password" + placeholder={_(msg`New password`)} placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoCorrect={false} @@ -128,7 +130,7 @@ export const SetNewPasswordForm = ({ editable={!isProcessing} accessible={true} accessibilityLabel={_(msg`Password`)} - accessibilityHint="Input new password" + accessibilityHint={_(msg`Input new password`)} /> </View> </View> @@ -161,7 +163,7 @@ export const SetNewPasswordForm = ({ onPress={onPressNext} accessibilityRole="button" accessibilityLabel={_(msg`Go to next`)} - accessibilityHint="Navigates to the next screen"> + accessibilityHint={_(msg`Navigates to the next screen`)}> <Text type="xl-bold" style={[pal.link, s.pr5]}> <Trans>Next</Trans> </Text> diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx index fcc4572af..63fb0ec15 100644 --- a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx @@ -18,6 +18,8 @@ import { } from '#/state/queries/preferences' import {logger} from '#/logger' import {useAnalytics} from '#/lib/analytics/analytics' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export function RecommendedFeedsItem({ item, @@ -26,6 +28,7 @@ export function RecommendedFeedsItem({ }) { const {isMobile} = useWebMediaQueries() const pal = usePalette('default') + const {_} = useLingui() const {data: preferences} = usePreferencesQuery() const { mutateAsync: pinFeed, @@ -51,7 +54,7 @@ export function RecommendedFeedsItem({ await removeFeed({uri: item.uri}) resetRemoveFeed() } catch (e) { - Toast.show('There was an issue contacting your server') + Toast.show(_(msg`There was an issue contacting your server`)) logger.error('Failed to unsave feed', {error: e}) } } else { @@ -60,7 +63,7 @@ export function RecommendedFeedsItem({ resetPinFeed() track('Onboarding:CustomFeedAdded') } catch (e) { - Toast.show('There was an issue contacting your server') + Toast.show(_(msg`There was an issue contacting your server`)) logger.error('Failed to pin feed', {error: e}) } } @@ -94,7 +97,7 @@ export function RecommendedFeedsItem({ </Text> <Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}> - by {sanitizeHandle(item.creator.handle, '@')} + <Trans>by {sanitizeHandle(item.creator.handle, '@')}</Trans> </Text> {item.description ? ( @@ -133,7 +136,7 @@ export function RecommendedFeedsItem({ color={pal.colors.textInverted} /> <Text type="lg-medium" style={pal.textInverted}> - Added + <Trans>Added</Trans> </Text> </> ) : ( @@ -144,7 +147,7 @@ export function RecommendedFeedsItem({ color={pal.colors.textInverted} /> <Text type="lg-medium" style={pal.textInverted}> - Add + <Trans>Add</Trans> </Text> </> )} diff --git a/src/view/com/auth/onboarding/RecommendedFollows.tsx b/src/view/com/auth/onboarding/RecommendedFollows.tsx index 372bbec6a..93cfb7386 100644 --- a/src/view/com/auth/onboarding/RecommendedFollows.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollows.tsx @@ -83,7 +83,7 @@ export function RecommendedFollows({next}: Props) { <Text type="2xl-medium" style={{color: '#fff', position: 'relative', top: -1}}> - <Trans>Done</Trans> + <Trans context="action">Done</Trans> </Text> <FontAwesomeIcon icon="angle-right" color="#fff" size={14} /> </View> diff --git a/src/view/com/auth/onboarding/WelcomeDesktop.tsx b/src/view/com/auth/onboarding/WelcomeDesktop.tsx index 1a30c17f9..fdb31197c 100644 --- a/src/view/com/auth/onboarding/WelcomeDesktop.tsx +++ b/src/view/com/auth/onboarding/WelcomeDesktop.tsx @@ -7,6 +7,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout' import {Button} from 'view/com/util/forms/Button' +import {Trans} from '@lingui/macro' type Props = { next: () => void @@ -17,7 +18,7 @@ export function WelcomeDesktop({next}: Props) { const pal = usePalette('default') const horizontal = useMediaQuery({minWidth: 1300}) const title = ( - <> + <Trans> <Text style={[ pal.textLight, @@ -40,7 +41,7 @@ export function WelcomeDesktop({next}: Props) { ]}> Bluesky </Text> - </> + </Trans> ) return ( <TitleColumnLayout @@ -52,10 +53,12 @@ export function WelcomeDesktop({next}: Props) { <FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} /> <View style={[styles.rowText]}> <Text type="xl-bold" style={[pal.text]}> - Bluesky is public. + <Trans>Bluesky is public.</Trans> </Text> <Text type="xl" style={[pal.text, s.pt2]}> - Your posts, likes, and blocks are public. Mutes are private. + <Trans> + Your posts, likes, and blocks are public. Mutes are private. + </Trans> </Text> </View> </View> @@ -63,10 +66,10 @@ export function WelcomeDesktop({next}: Props) { <FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} /> <View style={[styles.rowText]}> <Text type="xl-bold" style={[pal.text]}> - Bluesky is open. + <Trans>Bluesky is open.</Trans> </Text> <Text type="xl" style={[pal.text, s.pt2]}> - Never lose access to your followers and data. + <Trans>Never lose access to your followers and data.</Trans> </Text> </View> </View> @@ -74,10 +77,13 @@ export function WelcomeDesktop({next}: Props) { <FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} /> <View style={[styles.rowText]}> <Text type="xl-bold" style={[pal.text]}> - Bluesky is flexible. + <Trans>Bluesky is flexible.</Trans> </Text> <Text type="xl" style={[pal.text, s.pt2]}> - Choose the algorithms that power your experience with custom feeds. + <Trans> + Choose the algorithms that power your experience with custom + feeds. + </Trans> </Text> </View> </View> @@ -94,7 +100,7 @@ export function WelcomeDesktop({next}: Props) { <Text type="2xl-medium" style={{color: '#fff', position: 'relative', top: -1}}> - Next + <Trans context="action">Next</Trans> </Text> <FontAwesomeIcon icon="angle-right" color="#fff" size={14} /> </View> diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 9f60923d6..1ed6b98a5 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -6,6 +6,7 @@ import { Keyboard, KeyboardAvoidingView, Platform, + Pressable, ScrollView, StyleSheet, TouchableOpacity, @@ -28,8 +29,6 @@ import {UserAvatar} from '../util/UserAvatar' import * as apilib from 'lib/api/index' import {ComposerOpts} from 'state/shell/composer' import {s, colors, gradients} from 'lib/styles' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' import {cleanError} from 'lib/strings/errors' import {shortenLinks} from 'lib/strings/rich-text-manip' import {toShortUrl} from 'lib/strings/url-helpers' @@ -46,7 +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 {EmojiPickerButton} from './text-input/web/EmojiPicker.web' +import {SuggestedLanguage} from './select-language/SuggestedLanguage' import {insertMentionAt} from 'lib/strings/mention-manip' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -63,6 +62,7 @@ import {useComposerControls} from '#/state/shell/composer' import {emitPostCreated} from '#/state/events' import {ThreadgateSetting} from '#/state/queries/threadgate' import {logger} from '#/logger' +import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' type Props = ComposerOpts export const ComposePost = observer(function ComposePost({ @@ -70,10 +70,11 @@ export const ComposePost = observer(function ComposePost({ onPost, quote: initQuote, mention: initMention, + openPicker, }: 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) => { @@ -260,7 +261,11 @@ export const ComposePost = observer(function ComposePost({ setLangPrefs.savePostLanguageToHistory() onPost?.() onClose() - Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) + Toast.show( + replyTo + ? _(msg`Your reply has been published`) + : _(msg`Your post has been published`), + ) } const canPost = useMemo( @@ -269,11 +274,17 @@ export const ComposePost = observer(function ComposePost({ (!requireAltTextEnabled || !gallery.needsAltText), [graphemeLength, requireAltTextEnabled, gallery.needsAltText], ) - const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?` + const selectTextInputPlaceholder = replyTo + ? _(msg`Write your reply`) + : _(msg`What's up?`) const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size]) const hasMedia = gallery.size > 0 || Boolean(extLink) + const onEmojiButtonPress = useCallback(() => { + openPicker?.(textInput.current?.getCursorPosition()) + }, [openPicker]) + return ( <KeyboardAvoidingView testID="composePostView" @@ -287,7 +298,9 @@ export const ComposePost = observer(function ComposePost({ onAccessibilityEscape={onPressCancel} accessibilityRole="button" accessibilityLabel={_(msg`Cancel`)} - accessibilityHint="Closes post composer and discards post draft"> + accessibilityHint={_( + msg`Closes post composer and discards post draft`, + )}> <Text style={[pal.link, s.f18]}> <Trans>Cancel</Trans> </Text> @@ -319,7 +332,7 @@ export const ComposePost = observer(function ComposePost({ onPress={onPressPublish} accessibilityRole="button" accessibilityLabel={ - replyTo ? 'Publish reply' : 'Publish post' + replyTo ? _(msg`Publish reply`) : _(msg`Publish post`) } accessibilityHint=""> <LinearGradient @@ -331,14 +344,18 @@ export const ComposePost = observer(function ComposePost({ end={{x: 1, y: 1}} style={styles.postBtn}> <Text style={[s.white, s.f16, s.bold]}> - {replyTo ? 'Reply' : 'Post'} + {replyTo ? ( + <Trans context="action">Reply</Trans> + ) : ( + <Trans context="action">Post</Trans> + )} </Text> </LinearGradient> </TouchableOpacity> ) : ( <View style={[styles.postBtn, pal.btn]}> <Text style={[pal.textLight, s.f16, s.bold]}> - <Trans>Post</Trans> + <Trans context="action">Post</Trans> </Text> </View> )} @@ -374,22 +391,7 @@ export const ComposePost = observer(function ComposePost({ <ScrollView style={styles.scrollView} keyboardShouldPersistTaps="always"> - {replyTo ? ( - <View style={[pal.border, styles.replyToLayout]}> - <UserAvatar avatar={replyTo.author.avatar} size={50} /> - <View style={styles.replyToPost}> - <Text type="xl-medium" style={[pal.text]}> - {sanitizeDisplayName( - replyTo.author.displayName || - sanitizeHandle(replyTo.author.handle), - )} - </Text> - <Text type="post-text" style={pal.text} numberOfLines={6}> - {replyTo.text} - </Text> - </View> - </View> - ) : undefined} + {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined} <View style={[ @@ -411,7 +413,9 @@ export const ComposePost = observer(function ComposePost({ onError={setError} accessible={true} accessibilityLabel={_(msg`Write post`)} - accessibilityHint={`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`} + accessibilityHint={_( + msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`, + )} /> </View> @@ -440,7 +444,9 @@ export const ComposePost = observer(function ComposePost({ onPress={() => onPressAddLinkCard(url)} accessibilityRole="button" accessibilityLabel={_(msg`Add link card`)} - accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}> + accessibilityHint={_( + msg`Creates a card with a thumbnail. The card links to ${url}`, + )}> <Text style={pal.text}> <Trans>Add link card:</Trans>{' '} <Text style={[pal.link, s.ml5]}>{toShortUrl(url)}</Text> @@ -449,6 +455,7 @@ export const ComposePost = observer(function ComposePost({ ))} </View> ) : null} + <SuggestedLanguage text={richtext.text} /> <View style={[pal.border, styles.bottomBar]}> {canSelectImages ? ( <> @@ -456,7 +463,19 @@ export const ComposePost = observer(function ComposePost({ <OpenCameraBtn gallery={gallery} /> </> ) : null} - {!isMobile ? <EmojiPickerButton /> : null} + {!isMobile ? ( + <Pressable + onPress={onEmojiButtonPress} + accessibilityRole="button" + accessibilityLabel={_(msg`Open emoji picker`)} + accessibilityHint={_(msg`Open emoji picker`)}> + <FontAwesomeIcon + icon={['far', 'face-smile']} + color={pal.colors.link} + size={22} + /> + </Pressable> + ) : null} <View style={s.flex1} /> <SelectLangBtn /> <CharProgress count={graphemeLength} /> @@ -532,17 +551,6 @@ const styles = StyleSheet.create({ textInputLayoutMobile: { flex: 1, }, - replyToLayout: { - flexDirection: 'row', - borderTopWidth: 1, - paddingTop: 16, - paddingBottom: 16, - }, - replyToPost: { - flex: 1, - paddingLeft: 13, - paddingRight: 8, - }, addExtLinkBtn: { borderWidth: 1, borderRadius: 24, diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx new file mode 100644 index 000000000..678c8581f --- /dev/null +++ b/src/view/com/composer/ComposerReplyTo.tsx @@ -0,0 +1,254 @@ +import React from 'react' +import {LayoutAnimation, Pressable, StyleSheet, View} from 'react-native' +import {Image} from 'expo-image' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import { + AppBskyEmbedImages, + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyFeedPost, +} from '@atproto/api' +import {ComposerOptsPostRef} from 'state/shell/composer' +import {usePalette} from 'lib/hooks/usePalette' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {Text} from 'view/com/util/text/Text' +import QuoteEmbed from 'view/com/util/post-embeds/QuoteEmbed' + +export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { + const pal = usePalette('default') + const {_} = useLingui() + const {embed} = replyTo + + const [showFull, setShowFull] = React.useState(false) + + const onPress = React.useCallback(() => { + setShowFull(prev => !prev) + LayoutAnimation.configureNext({ + duration: 350, + update: {type: 'spring', springDamping: 0.7}, + }) + }, []) + + const quote = React.useMemo(() => { + if ( + AppBskyEmbedRecord.isView(embed) && + AppBskyEmbedRecord.isViewRecord(embed.record) && + AppBskyFeedPost.isRecord(embed.record.value) + ) { + // Not going to include the images right now + return { + author: embed.record.author, + cid: embed.record.cid, + uri: embed.record.uri, + indexedAt: embed.record.indexedAt, + text: embed.record.value.text, + } + } else if ( + AppBskyEmbedRecordWithMedia.isView(embed) && + AppBskyEmbedRecord.isViewRecord(embed.record.record) && + AppBskyFeedPost.isRecord(embed.record.record.value) + ) { + return { + author: embed.record.record.author, + cid: embed.record.record.cid, + uri: embed.record.record.uri, + indexedAt: embed.record.record.indexedAt, + text: embed.record.record.value.text, + } + } + }, [embed]) + + const images = React.useMemo(() => { + if (AppBskyEmbedImages.isView(embed)) { + return embed.images + } else if ( + AppBskyEmbedRecordWithMedia.isView(embed) && + AppBskyEmbedImages.isView(embed.media) + ) { + return embed.media.images + } + }, [embed]) + + return ( + <Pressable + style={[pal.border, styles.replyToLayout]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={_( + msg`Expand or collapse the full post you are replying to`, + )} + accessibilityHint={_( + msg`Expand or collapse the full post you are replying to`, + )}> + <UserAvatar avatar={replyTo.author.avatar} size={50} /> + <View style={styles.replyToPost}> + <Text type="xl-medium" style={[pal.text]}> + {sanitizeDisplayName( + replyTo.author.displayName || sanitizeHandle(replyTo.author.handle), + )} + </Text> + <View style={styles.replyToBody}> + <View style={styles.replyToText}> + <Text + type="post-text" + style={pal.text} + numberOfLines={!showFull ? 6 : undefined}> + {replyTo.text} + </Text> + </View> + {images && ( + <ComposerReplyToImages images={images} showFull={showFull} /> + )} + </View> + {showFull && quote && <QuoteEmbed quote={quote} />} + </View> + </Pressable> + ) +} + +function ComposerReplyToImages({ + images, +}: { + images: AppBskyEmbedImages.ViewImage[] + showFull: boolean +}) { + return ( + <View + style={{ + width: 65, + flexDirection: 'column', + alignItems: 'center', + }}> + <View style={styles.imagesContainer}> + {(images.length === 1 && ( + <Image + source={{uri: images[0].thumb}} + style={styles.singleImage} + cachePolicy="memory-disk" + accessibilityIgnoresInvertColors + /> + )) || + (images.length === 2 && ( + <View style={[styles.imagesInner, styles.imagesRow]}> + <Image + source={{uri: images[0].thumb}} + style={styles.doubleImageTall} + cachePolicy="memory-disk" + accessibilityIgnoresInvertColors + /> + <Image + source={{uri: images[1].thumb}} + style={styles.doubleImageTall} + cachePolicy="memory-disk" + accessibilityIgnoresInvertColors + /> + </View> + )) || + (images.length === 3 && ( + <View style={[styles.imagesInner, styles.imagesRow]}> + <Image + source={{uri: images[0].thumb}} + style={styles.doubleImageTall} + cachePolicy="memory-disk" + accessibilityIgnoresInvertColors + /> + <View style={styles.imagesInner}> + <Image + source={{uri: images[1].thumb}} + style={styles.doubleImage} + cachePolicy="memory-disk" + accessibilityIgnoresInvertColors + /> + <Image + source={{uri: images[2].thumb}} + style={styles.doubleImage} + cachePolicy="memory-disk" + accessibilityIgnoresInvertColors + /> + </View> + </View> + )) || + (images.length === 4 && ( + <View style={styles.imagesInner}> + <View style={[styles.imagesInner, styles.imagesRow]}> + <Image + source={{uri: images[0].thumb}} + style={styles.doubleImage} + cachePolicy="memory-disk" + accessibilityIgnoresInvertColors + /> + <Image + source={{uri: images[1].thumb}} + style={styles.doubleImage} + cachePolicy="memory-disk" + accessibilityIgnoresInvertColors + /> + </View> + <View style={[styles.imagesInner, styles.imagesRow]}> + <Image + source={{uri: images[2].thumb}} + style={styles.doubleImage} + cachePolicy="memory-disk" + accessibilityIgnoresInvertColors + /> + <Image + source={{uri: images[3].thumb}} + style={styles.doubleImage} + cachePolicy="memory-disk" + accessibilityIgnoresInvertColors + /> + </View> + </View> + ))} + </View> + </View> + ) +} + +const styles = StyleSheet.create({ + replyToLayout: { + flexDirection: 'row', + borderTopWidth: 1, + paddingTop: 16, + paddingBottom: 16, + }, + replyToPost: { + flex: 1, + paddingLeft: 13, + paddingRight: 8, + }, + replyToBody: { + flexDirection: 'row', + gap: 10, + }, + replyToText: { + flex: 1, + flexGrow: 1, + }, + imagesContainer: { + borderRadius: 6, + overflow: 'hidden', + marginTop: 2, + }, + imagesInner: { + gap: 2, + }, + imagesRow: { + flexDirection: 'row', + }, + singleImage: { + width: 65, + height: 65, + }, + doubleImageTall: { + width: 32.5, + height: 65, + }, + doubleImage: { + width: 32.5, + height: 32.5, + }, +}) diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 502e4b4d2..02dd1bbd7 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -68,7 +68,7 @@ export const ExternalEmbed = ({ onPress={onRemove} accessibilityRole="button" accessibilityLabel={_(msg`Remove image preview`)} - accessibilityHint={`Removes default thumbnail from ${link.uri}`} + accessibilityHint={_(msg`Removes default thumbnail from ${link.uri}`)} onAccessibilityEscape={onRemove}> <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> </TouchableOpacity> diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx index 9964359ac..632bb2634 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/composer/Prompt.tsx @@ -22,7 +22,7 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { onPress={() => onPressCompose()} accessibilityRole="button" accessibilityLabel={_(msg`Compose reply`)} - accessibilityHint="Opens composer"> + accessibilityHint={_(msg`Opens composer`)}> <UserAvatar avatar={profile?.avatar} size={38} /> <Text type="xl" diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index 69f63c55f..a288e7310 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -58,7 +58,7 @@ export function OpenCameraBtn({gallery}: Props) { hitSlop={HITSLOP_10} accessibilityRole="button" accessibilityLabel={_(msg`Camera`)} - accessibilityHint="Opens camera on device"> + accessibilityHint={_(msg`Opens camera on device`)}> <FontAwesomeIcon icon="camera" style={pal.link as FontAwesomeIconStyle} diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx index af0a22b01..f7fa9502d 100644 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -41,7 +41,7 @@ export function SelectPhotoBtn({gallery}: Props) { hitSlop={HITSLOP_10} accessibilityRole="button" accessibilityLabel={_(msg`Gallery`)} - accessibilityHint="Opens device photo gallery"> + accessibilityHint={_(msg`Opens device photo gallery`)}> <FontAwesomeIcon icon={['far', 'image']} style={pal.link as FontAwesomeIconStyle} 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.tsx b/src/view/com/composer/text-input/TextInput.tsx index 7e39f6aed..3d0d5ab8d 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -32,6 +32,7 @@ import {POST_IMG_MAX} from 'lib/constants' export interface TextInputRef { focus: () => void blur: () => void + getCursorPosition: () => DOMRect | undefined } interface TextInputProps extends ComponentProps<typeof RNTextInput> { @@ -74,6 +75,7 @@ export const TextInput = forwardRef(function TextInputImpl( blur: () => { textInput.current?.blur() }, + getCursorPosition: () => undefined, // Not implemented on native })) const onChangeText = useCallback( @@ -215,6 +217,7 @@ export const TextInput = forwardRef(function TextInputImpl( autoFocus={true} allowFontScaling multiline + scrollEnabled={false} numberOfLines={4} style={[ pal.text, diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 206a3205b..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,10 +18,16 @@ 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 blur: () => void + getCursorPosition: () => DOMRect | undefined } interface TextInputProps { @@ -52,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, @@ -67,7 +77,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( Placeholder.configure({ placeholder, }), - Text, + TiptapText, History, Hardbreak, ], @@ -87,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, @@ -169,12 +219,35 @@ export const TextInput = React.forwardRef(function TextInputImpl( React.useImperativeHandle(ref, () => ({ focus: () => {}, // TODO blur: () => {}, // TODO + getCursorPosition: () => { + const pos = editor?.state.selection.$anchor.pos + return pos ? editor?.view.coordsAtPos(pos) : undefined + }, })) 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> + )} + </> ) }) @@ -205,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( @@ -213,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/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index 51197b8e4..76058fed3 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -17,6 +17,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {Text} from 'view/com/util/text/Text' import {UserAvatar} from 'view/com/util/UserAvatar' import {useGrapheme} from '../hooks/useGrapheme' +import {Trans} from '@lingui/macro' interface MentionListRef { onKeyDown: (props: SuggestionKeyDownProps) => boolean @@ -187,7 +188,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>( }) ) : ( <Text type="sm" style={[pal.text, styles.noResult]}> - No result + <Trans>No result</Trans> </Text> )} </View> 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 f4b2d99b0..149362116 100644 --- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx +++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx @@ -1,11 +1,17 @@ import React from 'react' import Picker from '@emoji-mart/react' -import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' -import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import { + StyleSheet, + TouchableWithoutFeedback, + useWindowDimensions, + View, +} from 'react-native' import {textInputWebEmitter} from '../TextInput.web' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {usePalette} from 'lib/hooks/usePalette' -import {useMediaQuery} from 'react-responsive' + +const HEIGHT_OFFSET = 40 +const WIDTH_OFFSET = 100 +const PICKER_HEIGHT = 435 + HEIGHT_OFFSET +const PICKER_WIDTH = 350 + WIDTH_OFFSET export type Emoji = { aliases?: string[] @@ -18,59 +24,87 @@ export type Emoji = { unified: string } -export function EmojiPickerButton() { - const pal = usePalette('default') - const [open, setOpen] = React.useState(false) - const onOpenChange = (o: boolean) => { - setOpen(o) - } - const close = () => { - setOpen(false) - } +export interface EmojiPickerState { + isOpen: boolean + pos: {top: number; left: number; right: number; bottom: number} +} - return ( - <DropdownMenu.Root open={open} onOpenChange={onOpenChange}> - <DropdownMenu.Trigger style={styles.trigger}> - <FontAwesomeIcon - icon={['far', 'face-smile']} - color={pal.colors.link} - size={22} - /> - </DropdownMenu.Trigger> - - <DropdownMenu.Portal> - <EmojiPicker close={close} /> - </DropdownMenu.Portal> - </DropdownMenu.Root> - ) +interface IProps { + state: EmojiPickerState + close: () => void } -export function EmojiPicker({close}: {close: () => void}) { +export function EmojiPicker({state, close}: IProps) { + const {height, width} = useWindowDimensions() + + const isShiftDown = React.useRef(false) + + const position = React.useMemo(() => { + const fitsBelow = state.pos.top + PICKER_HEIGHT < height + const fitsAbove = PICKER_HEIGHT < state.pos.top + const placeOnLeft = PICKER_WIDTH < state.pos.left + const screenYMiddle = height / 2 - PICKER_HEIGHT / 2 + + if (fitsBelow) { + return { + top: state.pos.top + HEIGHT_OFFSET, + } + } else if (fitsAbove) { + return { + bottom: height - state.pos.bottom + HEIGHT_OFFSET, + } + } else { + return { + top: screenYMiddle, + left: placeOnLeft ? state.pos.left - PICKER_WIDTH : undefined, + right: !placeOnLeft + ? width - state.pos.right - PICKER_WIDTH + : undefined, + } + } + }, [state.pos, height, width]) + + React.useEffect(() => { + if (!state.isOpen) return + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + isShiftDown.current = true + } + } + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + isShiftDown.current = false + } + } + window.addEventListener('keydown', onKeyDown, true) + window.addEventListener('keyup', onKeyUp, true) + + return () => { + window.removeEventListener('keydown', onKeyDown, true) + window.removeEventListener('keyup', onKeyUp, true) + } + }, [state.isOpen]) + const onInsert = (emoji: Emoji) => { textInputWebEmitter.emit('emoji-inserted', emoji) - close() + + if (!isShiftDown.current) { + close() + } } - const reducedPadding = useMediaQuery({query: '(max-height: 750px)'}) - const noPadding = useMediaQuery({query: '(max-height: 550px)'}) - const noPicker = useMediaQuery({query: '(max-height: 350px)'}) + + if (!state.isOpen) return null return ( - // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors - <TouchableWithoutFeedback onPress={close} accessibilityViewIsModal> + <TouchableWithoutFeedback + accessibilityRole="button" + onPress={close} + accessibilityViewIsModal> <View style={styles.mask}> {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */} - <TouchableWithoutFeedback - onPress={e => { - e.stopPropagation() // prevent event from bubbling up to the mask - }}> - <View - style={[ - styles.picker, - { - paddingTop: noPadding ? 0 : reducedPadding ? 150 : 325, - display: noPicker ? 'none' : 'flex', - }, - ]}> + <TouchableWithoutFeedback onPress={e => e.stopPropagation()}> + <View style={[{position: 'absolute'}, position]}> <Picker data={async () => { return (await import('./EmojiPickerData.json')).default @@ -87,21 +121,14 @@ export function EmojiPicker({close}: {close: () => void}) { const styles = StyleSheet.create({ mask: { - position: 'absolute', + // @ts-ignore web ony + position: 'fixed', top: 0, left: 0, right: 0, width: '100%', height: '100%', - }, - trigger: { - backgroundColor: 'transparent', - // @ts-ignore web only -prf - border: 'none', - paddingTop: 4, - paddingLeft: 12, - paddingRight: 12, - cursor: 'pointer', + alignItems: 'center', }, picker: { marginHorizontal: 'auto', 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 84d49e3b0..9595e77e5 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -174,6 +174,7 @@ export function FeedPage({ feed={feed} feedParams={feedParams} pollInterval={POLL_FREQ} + disablePoll={hasNew} scrollElRef={scrollElRef} onScrolledDownChange={setIsScrolledDown} onHasNew={setHasNew} @@ -197,7 +198,7 @@ export function FeedPage({ onPress={onPressCompose} icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} + accessibilityLabel={_(msg({message: `New post`, context: 'action'}))} accessibilityHint="" /> )} @@ -209,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/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index 99e2b474f..487163840 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -14,7 +14,7 @@ import * as Toast from 'view/com/util/Toast' import {sanitizeHandle} from 'lib/strings/handles' import {logger} from '#/logger' import {useModalControls} from '#/state/modals' -import {msg} from '@lingui/macro' +import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import { usePinFeedMutation, @@ -108,9 +108,9 @@ export function FeedSourceCardLoaded({ try { await removeFeed({uri: feed.uri}) // await item.unsave() - Toast.show('Removed from my feeds') + Toast.show(_(msg`Removed from my feeds`)) } catch (e) { - Toast.show('There was an issue contacting your server') + Toast.show(_(msg`There was an issue contacting your server`)) logger.error('Failed to unsave feed', {error: e}) } }, @@ -122,9 +122,9 @@ export function FeedSourceCardLoaded({ } else { await saveFeed({uri: feed.uri}) } - Toast.show('Added to my feeds') + Toast.show(_(msg`Added to my feeds`)) } catch (e) { - Toast.show('There was an issue contacting your server') + Toast.show(_(msg`There was an issue contacting your server`)) logger.error('Failed to save feed', {error: e}) } } @@ -164,7 +164,7 @@ export function FeedSourceCardLoaded({ testID={`feed-${feedUri}-toggleSave`} disabled={isRemovePending} accessibilityRole="button" - accessibilityLabel={'Remove from my feeds'} + accessibilityLabel={_(msg`Remove from my feeds`)} accessibilityHint="" onPress={() => { openModal({ @@ -175,9 +175,11 @@ export function FeedSourceCardLoaded({ try { await removeFeed({uri: feedUri}) // await item.unsave() - Toast.show('Removed from my feeds') + Toast.show(_(msg`Removed from my feeds`)) } catch (e) { - Toast.show('There was an issue contacting your server') + Toast.show( + _(msg`There was an issue contacting your server`), + ) logger.error('Failed to unsave feed', {error: e}) } }, @@ -223,19 +225,22 @@ export function FeedSourceCardLoaded({ {feed.displayName} </Text> <Text style={[pal.textLight]} numberOfLines={3}> - {feed.type === 'feed' ? 'Feed' : 'List'} by{' '} - {sanitizeHandle(feed.creatorHandle, '@')} + {feed.type === 'feed' ? ( + <Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans> + ) : ( + <Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans> + )} </Text> </View> {showSaveBtn && feed.type === 'feed' && ( - <View> + <View style={[s.justifyCenter]}> <Pressable testID={`feed-${feed.displayName}-toggleSave`} disabled={isSavePending || isPinPending || isRemovePending} accessibilityRole="button" accessibilityLabel={ - isSaved ? 'Remove from my feeds' : 'Add to my feeds' + isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`) } accessibilityHint="" onPress={onToggleSaved} @@ -269,8 +274,10 @@ export function FeedSourceCardLoaded({ {showLikes && feed.type === 'feed' ? ( <Text type="sm-medium" style={[pal.text, pal.textLight]}> - Liked by {feed.likeCount || 0}{' '} - {pluralize(feed.likeCount || 0, 'user')} + <Trans> + Liked by {feed.likeCount || 0}{' '} + {pluralize(feed.likeCount || 0, 'user')} + </Trans> </Text> ) : null} </Pressable> diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx index 8665fbfac..f558eb18c 100644 --- a/src/view/com/feeds/ProfileFeedgens.tsx +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -9,13 +9,14 @@ import {Text} from '../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {useProfileFeedgensQuery, RQKEY} from '#/state/queries/profile-feedgens' import {logger} from '#/logger' -import {Trans} from '@lingui/macro' +import {Trans, msg} from '@lingui/macro' import {cleanError} from '#/lib/strings/errors' import {useTheme} from '#/lib/ThemeContext' import {usePreferencesQuery} from '#/state/queries/preferences' import {hydrateFeedGenerator} from '#/state/queries/feed' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {isNative} from '#/platform/detection' +import {useLingui} from '@lingui/react' const LOADING = {_reactKey: '__loading__'} const EMPTY = {_reactKey: '__empty__'} @@ -43,6 +44,7 @@ export const ProfileFeedgens = React.forwardRef< ref, ) { const pal = usePalette('default') + const {_} = useLingui() const theme = useTheme() const [isPTRing, setIsPTRing] = React.useState(false) const opts = React.useMemo(() => ({enabled}), [enabled]) @@ -142,7 +144,9 @@ export const ProfileFeedgens = React.forwardRef< } else if (item === LOAD_MORE_ERROR_ITEM) { return ( <LoadMoreRetryBtn - label="There was an issue fetching your lists. Tap here to try again." + label={_( + msg`There was an issue fetching your lists. Tap here to try again.`, + )} onPress={onPressRetryLoadMore} /> ) @@ -162,7 +166,7 @@ export const ProfileFeedgens = React.forwardRef< } return null }, - [error, refetch, onPressRetryLoadMore, pal, preferences], + [error, refetch, onPressRetryLoadMore, pal, preferences, _], ) return ( diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx index c806bc6a6..3401adaff 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx @@ -24,7 +24,7 @@ const ImageDefaultHeader = ({onRequestClose}: Props) => ( hitSlop={HIT_SLOP} accessibilityRole="button" accessibilityLabel={t`Close image`} - accessibilityHint="Closes viewer for header image" + accessibilityHint={t`Closes viewer for header image`} onAccessibilityEscape={onRequestClose}> <Text style={styles.closeText}>✕</Text> </TouchableOpacity> diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx index ea740ec91..003ad61ba 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -320,6 +320,7 @@ const ImageItem = ({ accessibilityLabel={imageSrc.alt} accessibilityHint="" onLoad={() => setIsLoaded(true)} + cachePolicy="memory" /> </GestureDetector> </Animated.View> diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index 8a18df33f..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' @@ -15,6 +15,8 @@ import { ProfileImageLightbox, ImagesLightbox, } from '#/state/lightbox' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export function Lightbox() { const {activeLightbox} = useLightbox() @@ -53,6 +55,7 @@ export function Lightbox() { } function LightboxFooter({imageIndex}: {imageIndex: number}) { + const {_} = useLingui() const {activeLightbox} = useLightbox() const [isAltExpanded, setAltExpanded] = React.useState(false) const [permissionResponse, requestPermission] = MediaLibrary.usePermissions() @@ -60,12 +63,14 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) { const saveImageToAlbumWithToasts = React.useCallback( async (uri: string) => { if (!permissionResponse || permissionResponse.granted === false) { - Toast.show('Permission to access camera roll is required.') + Toast.show(_(msg`Permission to access camera roll is required.`)) if (permissionResponse?.canAskAgain) { requestPermission() } else { Toast.show( - 'Permission to access camera roll was denied. Please enable it in your system settings.', + _( + msg`Permission to access camera roll was denied. Please enable it in your system settings.`, + ), ) } return @@ -78,7 +83,7 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) { Toast.show(`Failed to save image: ${String(e)}`) } }, - [permissionResponse, requestPermission], + [permissionResponse, requestPermission, _], ) const lightbox = activeLightbox @@ -100,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 @@ -117,7 +128,7 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) { onPress={() => saveImageToAlbumWithToasts(uri)}> <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} /> <Text type="xl" style={s.white}> - Save + <Trans context="action">Save</Trans> </Text> </Button> <Button @@ -126,7 +137,7 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) { onPress={() => shareImageModal({uri})}> <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} /> <Text type="xl" style={s.white}> - Share + <Trans context="action">Share</Trans> </Text> </Button> </View> diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index 45e1fa5a3..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 } @@ -110,13 +117,13 @@ function LightboxInner({ onPress={onClose} accessibilityRole="button" accessibilityLabel={_(msg`Close image viewer`)} - accessibilityHint="Exits image view" + accessibilityHint={_(msg`Exits image view`)} onAccessibilityEscape={onClose}> <View style={styles.imageCenterer}> <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> @@ -154,7 +161,9 @@ function LightboxInner({ <View style={styles.footer}> <Pressable accessibilityLabel={_(msg`Expand alt text`)} - accessibilityHint="If alt text is long, toggles alt text expanded state" + accessibilityHint={_( + msg`If alt text is long, toggles alt text expanded state`, + )} onPress={() => { setAltExpanded(!isAltExpanded) }}> @@ -176,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 774e9e916..5750faec1 100644 --- a/src/view/com/lists/ListCard.tsx +++ b/src/view/com/lists/ListCard.tsx @@ -11,6 +11,7 @@ import {useSession} from '#/state/session' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' +import {Trans} from '@lingui/macro' export const ListCard = ({ testID, @@ -76,23 +77,40 @@ export const ListCard = ({ {sanitizeDisplayName(list.name)} </Text> <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {list.purpose === 'app.bsky.graph.defs#curatelist' && 'User list '} + {list.purpose === 'app.bsky.graph.defs#curatelist' && + (list.creator.did === currentAccount?.did ? ( + <Trans>User list by you</Trans> + ) : ( + <Trans> + User list by {sanitizeHandle(list.creator.handle, '@')} + </Trans> + ))} {list.purpose === 'app.bsky.graph.defs#modlist' && - 'Moderation list '} - by{' '} - {list.creator.did === currentAccount?.did - ? 'you' - : sanitizeHandle(list.creator.handle, '@')} + (list.creator.did === currentAccount?.did ? ( + <Trans>Moderation list by you</Trans> + ) : ( + <Trans> + Moderation list by {sanitizeHandle(list.creator.handle, '@')} + </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}> - Subscribed + <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/lists/ListMembers.tsx b/src/view/com/lists/ListMembers.tsx index 932f4b512..212244cd8 100644 --- a/src/view/com/lists/ListMembers.tsx +++ b/src/view/com/lists/ListMembers.tsx @@ -20,6 +20,8 @@ import {logger} from '#/logger' import {useModalControls} from '#/state/modals' import {useSession} from '#/state/session' import {cleanError} from '#/lib/strings/errors' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' const LOADING_ITEM = {_reactKey: '__loading__'} const EMPTY_ITEM = {_reactKey: '__empty__'} @@ -50,6 +52,7 @@ export function ListMembers({ desktopFixedHeightOffset?: number }) { const {track} = useAnalytics() + const {_} = useLingui() const [isRefreshing, setIsRefreshing] = React.useState(false) const {isMobile} = useWebMediaQueries() const {openModal} = useModalControls() @@ -143,12 +146,12 @@ export function ListMembers({ <Button testID={`user-${profile.handle}-editBtn`} type="default" - label="Edit" + label={_(msg({message: 'Edit', context: 'action'}))} onPress={() => onPressEditMembership(profile)} /> ) }, - [isOwner, onPressEditMembership], + [isOwner, onPressEditMembership, _], ) const renderItem = React.useCallback( @@ -165,7 +168,9 @@ export function ListMembers({ } else if (item === LOAD_MORE_ERROR_ITEM) { return ( <LoadMoreRetryBtn - label="There was an issue fetching the list. Tap here to try again." + label={_( + msg`There was an issue fetching the list. Tap here to try again.`, + )} onPress={onPressRetryLoadMore} /> ) @@ -191,6 +196,7 @@ export function ListMembers({ onPressTryAgain, onPressRetryLoadMore, isMobile, + _, ], ) diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx index db981717f..89d6ab480 100644 --- a/src/view/com/lists/ProfileLists.tsx +++ b/src/view/com/lists/ProfileLists.tsx @@ -10,11 +10,12 @@ import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {useProfileListsQuery, RQKEY} from '#/state/queries/profile-lists' import {logger} from '#/logger' -import {Trans} from '@lingui/macro' +import {Trans, msg} from '@lingui/macro' import {cleanError} from '#/lib/strings/errors' import {useTheme} from '#/lib/ThemeContext' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {isNative} from '#/platform/detection' +import {useLingui} from '@lingui/react' const LOADING = {_reactKey: '__loading__'} const EMPTY = {_reactKey: '__empty__'} @@ -42,6 +43,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( const pal = usePalette('default') const theme = useTheme() const {track} = useAnalytics() + const {_} = useLingui() const [isPTRing, setIsPTRing] = React.useState(false) const opts = React.useMemo(() => ({enabled}), [enabled]) const { @@ -149,7 +151,9 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( } else if (item === LOAD_MORE_ERROR_ITEM) { return ( <LoadMoreRetryBtn - label="There was an issue fetching your lists. Tap here to try again." + label={_( + msg`There was an issue fetching your lists. Tap here to try again.`, + )} onPress={onPressRetryLoadMore} /> ) @@ -164,7 +168,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( /> ) }, - [error, refetch, onPressRetryLoadMore, pal], + [error, refetch, onPressRetryLoadMore, pal, _], ) return ( diff --git a/src/view/com/modals/AddAppPasswords.tsx b/src/view/com/modals/AddAppPasswords.tsx index 812a36f45..7ec8268be 100644 --- a/src/view/com/modals/AddAppPasswords.tsx +++ b/src/view/com/modals/AddAppPasswords.tsx @@ -72,10 +72,10 @@ export function Component({}: {}) { const onCopy = React.useCallback(() => { if (appPassword) { Clipboard.setString(appPassword) - Toast.show('Copied to clipboard') + Toast.show(_(msg`Copied to clipboard`)) setWasCopied(true) } - }, [appPassword]) + }, [appPassword, _]) const onDone = React.useCallback(() => { closeModal() @@ -85,7 +85,9 @@ export function Component({}: {}) { // if name is all whitespace, we don't allow it if (!name || !name.trim()) { Toast.show( - 'Please enter a name for your app password. All spaces is not allowed.', + _( + msg`Please enter a name for your app password. All spaces is not allowed.`, + ), 'times', ) return @@ -93,14 +95,14 @@ export function Component({}: {}) { // if name is too short (under 4 chars), we don't allow it if (name.length < 4) { Toast.show( - 'App Password names must be at least 4 characters long.', + _(msg`App Password names must be at least 4 characters long.`), 'times', ) return } if (passwords?.find(p => p.name === name)) { - Toast.show('This name is already in use', 'times') + Toast.show(_(msg`This name is already in use`), 'times') return } @@ -109,11 +111,11 @@ export function Component({}: {}) { if (newPassword) { setAppPassword(newPassword.password) } else { - Toast.show('Failed to create app password.', 'times') + Toast.show(_(msg`Failed to create app password.`), 'times') // TODO: better error handling (?) } } catch (e) { - Toast.show('Failed to create app password.', 'times') + Toast.show(_(msg`Failed to create app password.`), 'times') logger.error('Failed to create app password', {error: e}) } } @@ -127,7 +129,9 @@ export function Component({}: {}) { setName(text) } else { Toast.show( - 'App Password names can only contain letters, numbers, spaces, dashes, and underscores.', + _( + msg`App Password names can only contain letters, numbers, spaces, dashes, and underscores.`, + ), ) } } @@ -158,7 +162,7 @@ export function Component({}: {}) { style={[styles.input, pal.text]} onChangeText={_onChangeText} value={name} - placeholder="Enter a name for this App Password" + placeholder={_(msg`Enter a name for this App Password`)} placeholderTextColor={pal.colors.textLight} autoCorrect={false} autoComplete="off" @@ -175,7 +179,7 @@ export function Component({}: {}) { onEndEditing={createAppPassword} accessible={true} accessibilityLabel={_(msg`Name`)} - accessibilityHint="Input name for app password" + accessibilityHint={_(msg`Input name for app password`)} /> </View> ) : ( @@ -184,7 +188,7 @@ export function Component({}: {}) { onPress={onCopy} accessibilityRole="button" accessibilityLabel={_(msg`Copy`)} - accessibilityHint="Copies app password"> + accessibilityHint={_(msg`Copies app password`)}> <Text type="2xl-bold" style={[pal.text]}> {appPassword} </Text> @@ -221,7 +225,7 @@ export function Component({}: {}) { <View style={styles.btnContainer}> <Button type="primary" - label={!appPassword ? 'Create App Password' : 'Done'} + label={!appPassword ? _(msg`Create App Password`) : _(msg`Done`)} style={styles.btn} labelStyle={styles.btnLabel} onPress={!appPassword ? createAppPassword : onDone} diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx index a2e918317..5156511d6 100644 --- a/src/view/com/modals/AltImage.tsx +++ b/src/view/com/modals/AltImage.tsx @@ -1,14 +1,12 @@ import React, {useMemo, useCallback, useState} from 'react' import { ImageStyle, - KeyboardAvoidingView, - ScrollView, StyleSheet, - TextInput, TouchableOpacity, View, useWindowDimensions, } from 'react-native' +import {ScrollView, TextInput} from './util' import {Image} from 'expo-image' import {usePalette} from 'lib/hooks/usePalette' import {gradients, s} from 'lib/styles' @@ -17,13 +15,13 @@ import {MAX_ALT_TEXT} from 'lib/constants' import {useTheme} from 'lib/ThemeContext' import {Text} from '../util/text/Text' import LinearGradient from 'react-native-linear-gradient' -import {isAndroid, isWeb} from 'platform/detection' +import {isWeb} from 'platform/detection' import {ImageModel} from 'state/models/media/image' import {useLingui} from '@lingui/react' import {Trans, msg} from '@lingui/macro' import {useModalControls} from '#/state/modals' -export const snapPoints = ['fullscreen'] +export const snapPoints = ['100%'] interface Props { image: ImageModel @@ -54,102 +52,86 @@ export function Component({image}: Props) { } }, [image, windim]) + const onUpdate = useCallback( + (v: string) => { + v = enforceLen(v, MAX_ALT_TEXT) + setAltText(v) + image.setAltText(v) + }, + [setAltText, image], + ) + const onPressSave = useCallback(() => { image.setAltText(altText) closeModal() }, [closeModal, image, altText]) - const onPressCancel = () => { - closeModal() - } - return ( - <KeyboardAvoidingView - behavior={isAndroid ? 'height' : 'padding'} - style={[pal.view, styles.container]}> - <ScrollView - testID="altTextImageModal" - style={styles.scrollContainer} - keyboardShouldPersistTaps="always" - nativeID="imageAltText"> - <View style={styles.scrollInner}> - <View style={[pal.viewLight, styles.imageContainer]}> - <Image - testID="selectedPhotoImage" - style={imageStyles} - source={{ - uri: image.cropped?.path ?? image.path, - }} - contentFit="contain" - accessible={true} - accessibilityIgnoresInvertColors - /> - </View> - <TextInput - testID="altTextImageInput" - style={[styles.textArea, pal.border, pal.text]} - keyboardAppearance={theme.colorScheme} - multiline - placeholder="Add alt text" - placeholderTextColor={pal.colors.textLight} - value={altText} - onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} - accessibilityLabel={_(msg`Image alt text`)} - accessibilityHint="" - accessibilityLabelledBy="imageAltText" - autoFocus + <ScrollView + testID="altTextImageModal" + style={[pal.view, styles.scrollContainer]} + keyboardShouldPersistTaps="always" + nativeID="imageAltText"> + <View style={styles.scrollInner}> + <View style={[pal.viewLight, styles.imageContainer]}> + <Image + testID="selectedPhotoImage" + style={imageStyles} + source={{ + uri: image.cropped?.path ?? image.path, + }} + contentFit="contain" + accessible={true} + accessibilityIgnoresInvertColors /> - <View style={styles.buttonControls}> - <TouchableOpacity - testID="altTextImageSaveBtn" - onPress={onPressSave} - accessibilityLabel={_(msg`Save alt text`)} - accessibilityHint={`Saves alt text, which reads: ${altText}`} - accessibilityRole="button"> - <LinearGradient - colors={[gradients.blueLight.start, gradients.blueLight.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.button]}> - <Text type="button-lg" style={[s.white, s.bold]}> - <Trans>Save</Trans> - </Text> - </LinearGradient> - </TouchableOpacity> - <TouchableOpacity - testID="altTextImageCancelBtn" - onPress={onPressCancel} - accessibilityRole="button" - accessibilityLabel={_(msg`Cancel add image alt text`)} - accessibilityHint="" - onAccessibilityEscape={onPressCancel}> - <View style={[styles.button]}> - <Text type="button-lg" style={[pal.textLight]}> - <Trans>Cancel</Trans> - </Text> - </View> - </TouchableOpacity> - </View> </View> - </ScrollView> - </KeyboardAvoidingView> + <TextInput + testID="altTextImageInput" + style={[styles.textArea, pal.border, pal.text]} + keyboardAppearance={theme.colorScheme} + multiline + placeholder={_(msg`Add alt text`)} + placeholderTextColor={pal.colors.textLight} + value={altText} + onChangeText={onUpdate} + accessibilityLabel={_(msg`Image alt text`)} + accessibilityHint="" + accessibilityLabelledBy="imageAltText" + autoFocus + /> + <View style={styles.buttonControls}> + <TouchableOpacity + testID="altTextImageSaveBtn" + onPress={onPressSave} + accessibilityLabel={_(msg`Save alt text`)} + accessibilityHint="" + accessibilityRole="button"> + <LinearGradient + colors={[gradients.blueLight.start, gradients.blueLight.end]} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[styles.button]}> + <Text type="button-lg" style={[s.white, s.bold]}> + <Trans>Done</Trans> + </Text> + </LinearGradient> + </TouchableOpacity> + </View> + </View> + </ScrollView> ) } const styles = StyleSheet.create({ - container: { - flex: 1, - height: '100%', - width: '100%', - paddingVertical: isWeb ? 0 : 18, - }, scrollContainer: { flex: 1, height: '100%', paddingHorizontal: isWeb ? 0 : 12, + paddingVertical: isWeb ? 0 : 24, }, scrollInner: { gap: 12, + paddingTop: isWeb ? 0 : 12, }, imageContainer: { borderRadius: 8, @@ -173,5 +155,6 @@ const styles = StyleSheet.create({ }, buttonControls: { gap: 8, + paddingBottom: isWeb ? 0 : 50, }, }) diff --git a/src/view/com/modals/AppealLabel.tsx b/src/view/com/modals/AppealLabel.tsx index edc6f4cd0..b0aaaf625 100644 --- a/src/view/com/modals/AppealLabel.tsx +++ b/src/view/com/modals/AppealLabel.tsx @@ -38,14 +38,14 @@ 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, }, reason: details, }) - Toast.show("We'll look into your appeal promptly.") + Toast.show(_(msg`We'll look into your appeal promptly.`)) } finally { closeModal() } diff --git a/src/view/com/modals/BirthDateSettings.tsx b/src/view/com/modals/BirthDateSettings.tsx index c78f06ed4..5ebc61137 100644 --- a/src/view/com/modals/BirthDateSettings.tsx +++ b/src/view/com/modals/BirthDateSettings.tsx @@ -23,7 +23,7 @@ import { } from '#/state/queries/preferences' import {logger} from '#/logger' -export const snapPoints = ['50%'] +export const snapPoints = ['50%', '90%'] function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) { const pal = usePalette('default') @@ -63,6 +63,7 @@ function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) { <View> <DateInput + handleAsUTC testID="birthdayInput" value={date} onChange={setDate} @@ -70,7 +71,7 @@ function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) { buttonStyle={[pal.border, styles.dateInputButton]} buttonLabelType="lg" accessibilityLabel={_(msg`Birthday`)} - accessibilityHint="Enter your birth date" + accessibilityHint={_(msg`Enter your birth date`)} accessibilityLabelledBy="birthDate" /> </View> diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx index 44b102fa0..c5672bc81 100644 --- a/src/view/com/modals/ChangeEmail.tsx +++ b/src/view/com/modals/ChangeEmail.tsx @@ -38,7 +38,7 @@ export function Component() { const onRequestChange = async () => { if (email === currentAccount?.email) { - setError('Enter your new email above') + setError(_(msg`Enter your new email above`)) return } setError('') @@ -53,7 +53,7 @@ export function Component() { email: email.trim(), emailConfirmed: false, }) - Toast.show('Email updated') + Toast.show(_(msg`Email updated`)) setStage(Stages.Done) } } catch (e) { @@ -85,7 +85,7 @@ export function Component() { email: email.trim(), emailConfirmed: false, }) - Toast.show('Email updated') + Toast.show(_(msg`Email updated`)) setStage(Stages.Done) } catch (e) { setError(cleanError(String(e))) diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index 31f6d6ea7..e578fa7da 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -147,7 +147,7 @@ export function Inner({ onPress={onPressCancel} accessibilityRole="button" accessibilityLabel={_(msg`Cancel change handle`)} - accessibilityHint="Exits handle change process" + accessibilityHint={_(msg`Exits handle change process`)} onAccessibilityEscape={onPressCancel}> <Text type="lg" style={pal.textLight}> Cancel @@ -168,7 +168,7 @@ export function Inner({ onPress={onPressSave} accessibilityRole="button" accessibilityLabel={_(msg`Save handle change`)} - accessibilityHint={`Saves handle change to ${handle}`}> + accessibilityHint={_(msg`Saves handle change to ${handle}`)}> <Text type="2xl-medium" style={pal.link}> <Trans>Save</Trans> </Text> @@ -263,14 +263,16 @@ function ProvidedHandleForm({ editable={!isProcessing} accessible={true} accessibilityLabel={_(msg`Handle`)} - accessibilityHint="Sets Bluesky username" + accessibilityHint={_(msg`Sets Bluesky username`)} /> </View> <Text type="md" style={[pal.textLight, s.pl10, s.pt10]}> - <Trans>Your full handle will be</Trans>{' '} - <Text type="md-bold" style={pal.textLight}> - @{createFullHandle(handle, userDomain)} - </Text> + <Trans> + Your full handle will be{' '} + <Text type="md-bold" style={pal.textLight}> + @{createFullHandle(handle, userDomain)} + </Text> + </Trans> </Text> <TouchableOpacity onPress={onToggleCustom} diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx index 5e869f396..307897fb8 100644 --- a/src/view/com/modals/Confirm.tsx +++ b/src/view/com/modals/Confirm.tsx @@ -12,7 +12,7 @@ import {cleanError} from 'lib/strings/errors' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' import {useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' +import {Trans, msg} from '@lingui/macro' import type {ConfirmModal} from '#/state/modals' import {useModalControls} from '#/state/modals' @@ -72,10 +72,10 @@ export function Component({ onPress={onPress} style={[styles.btn, confirmBtnStyle]} accessibilityRole="button" - accessibilityLabel={_(msg`Confirm`)} + accessibilityLabel={_(msg({message: 'Confirm', context: 'action'}))} accessibilityHint=""> <Text style={[s.white, s.bold, s.f18]}> - {confirmBtnText ?? 'Confirm'} + {confirmBtnText ?? <Trans context="action">Confirm</Trans>} </Text> </TouchableOpacity> )} @@ -85,10 +85,10 @@ export function Component({ onPress={onPressCancel} style={[styles.btnCancel, s.mt10]} accessibilityRole="button" - accessibilityLabel={_(msg`Cancel`)} + accessibilityLabel={_(msg({message: 'Cancel', context: 'action'}))} accessibilityHint=""> <Text type="button-lg" style={pal.textLight}> - {cancelBtnText ?? 'Cancel'} + {cancelBtnText ?? <Trans context="action">Cancel</Trans>} </Text> </TouchableOpacity> )} diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx index 8b42e1b1d..d681fbf0b 100644 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -104,6 +104,7 @@ export function Component({}: {}) { function AdultContentEnabledPref() { const pal = usePalette('default') + const {_} = useLingui() const {data: preferences} = usePreferencesQuery() const {mutate, variables} = usePreferencesSetAdultContentMutation() const {openModal} = useModalControls() @@ -121,36 +122,44 @@ function AdultContentEnabledPref() { enabled: !(variables?.enabled ?? preferences?.adultContentEnabled), }) } catch (e) { - Toast.show('There was an issue syncing your preferences with the server') + Toast.show( + _(msg`There was an issue syncing your preferences with the server`), + ) logger.error('Failed to update preferences with server', {error: e}) } - }, [variables, preferences, mutate]) + }, [variables, preferences, mutate, _]) return ( <View style={s.mb10}> {isIOS ? ( preferences?.adultContentEnabled ? null : ( <Text type="md" style={pal.textLight}> - Adult content can only be enabled via the Web at{' '} - <TextLink - style={pal.link} - href="https://bsky.app" - text="bsky.app" - /> - . + <Trans> + Adult content can only be enabled via the Web at{' '} + <TextLink + style={pal.link} + href="https://bsky.app" + text="bsky.app" + /> + . + </Trans> </Text> ) ) : typeof preferences?.birthDate === 'undefined' ? ( <View style={[pal.viewLight, styles.agePrompt]}> <Text type="md" style={[pal.text, {flex: 1}]}> - Confirm your age to enable adult content. + <Trans>Confirm your age to enable adult content.</Trans> </Text> - <Button type="primary" label="Set Age" onPress={onSetAge} /> + <Button + type="primary" + label={_(msg({message: 'Set Age', context: 'action'}))} + onPress={onSetAge} + /> </View> ) : (preferences.userAge || 0) >= 18 ? ( <ToggleButton type="default-light" - label="Enable Adult Content" + label={_(msg`Enable Adult Content`)} isSelected={variables?.enabled ?? preferences?.adultContentEnabled} onPress={onToggleAdultContent} style={styles.toggleBtn} @@ -158,9 +167,13 @@ function AdultContentEnabledPref() { ) : ( <View style={[pal.viewLight, styles.agePrompt]}> <Text type="md" style={[pal.text, {flex: 1}]}> - You must be 18 or older to enable adult content. + <Trans>You must be 18 or older to enable adult content.</Trans> </Text> - <Button type="primary" label="Set Age" onPress={onSetAge} /> + <Button + type="primary" + label={_(msg({message: 'Set Age', context: 'action'}))} + onPress={onSetAge} + /> </View> )} </View> @@ -203,7 +216,7 @@ function ContentLabelPref({ {disabled || !visibility ? ( <Text type="sm-bold" style={pal.textLight}> - Hide + <Trans context="action">Hide</Trans> </Text> ) : ( <SelectGroup @@ -223,12 +236,14 @@ interface SelectGroupProps { } function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) { + const {_} = useLingui() + return ( <View style={styles.selectableBtns}> <SelectableBtn current={current} value="hide" - label="Hide" + label={_(msg`Hide`)} left onChange={onChange} labelGroup={labelGroup} @@ -236,14 +251,14 @@ function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) { <SelectableBtn current={current} value="warn" - label="Warn" + label={_(msg`Warn`)} onChange={onChange} labelGroup={labelGroup} /> <SelectableBtn current={current} value="ignore" - label="Show" + label={_(msg`Show`)} right onChange={onChange} labelGroup={labelGroup} @@ -273,6 +288,8 @@ function SelectableBtn({ }: SelectableBtnProps) { const pal = usePalette('default') const palPrimary = usePalette('inverted') + const {_} = useLingui() + return ( <Pressable style={[ @@ -285,7 +302,9 @@ function SelectableBtn({ onPress={() => onChange(value)} accessibilityRole="button" accessibilityLabel={value} - accessibilityHint={`Set ${value} for ${labelGroup} content moderation policy`}> + accessibilityHint={_( + msg`Set ${value} for ${labelGroup} content moderation policy`, + )}> <Text style={current === value ? palPrimary.text : pal.text}> {label} </Text> diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx index 8d13cdf2f..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 @@ -65,16 +72,45 @@ export function Component({ return 'app.bsky.graph.defs#curatelist' }, [list, purpose]) const isCurateList = activePurpose === 'app.bsky.graph.defs#curatelist' - const purposeLabel = isCurateList ? 'User' : 'Moderation' 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]) @@ -106,7 +142,7 @@ export function Component({ } const nameTrimmed = name.trim() if (!nameTrimmed) { - setError('Name is required') + setError(_(msg`Name is required`)) return } setProcessing(true) @@ -114,30 +150,61 @@ 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(`${purposeLabel} list updated`) + Toast.show( + isCurateList + ? _(msg`User list updated`) + : _(msg`Moderation list updated`), + ) onSave?.(list.uri) } else { const res = await listCreateMutation.mutateAsync({ purpose: activePurpose, name, - description, + description: richText.text, + descriptionFacets: richText.facets, avatar: newAvatar, }) - Toast.show(`${purposeLabel} list created`) + Toast.show( + isCurateList + ? _(msg`User list created`) + : _(msg`Moderation list created`), + ) onSave?.(res.uri) } closeModal() } catch (e: any) { if (isNetworkError(e)) { setError( - 'Failed to create the list. Check your internet connection and try again.', + _( + msg`Failed to create the list. Check your internet connection and try again.`, + ), ) } else { setError(cleanError(e)) @@ -153,13 +220,13 @@ export function Component({ closeModal, activePurpose, isCurateList, - purposeLabel, name, - description, + descriptionRt, newAvatar, list, listMetadataMutation, listCreateMutation, + _, ]) return ( @@ -173,9 +240,17 @@ export function Component({ ]} testID="createOrEditListModal"> <Text style={[styles.title, pal.text]}> - <Trans> - {list ? 'Edit' : 'New'} {purposeLabel} List - </Trans> + {isCurateList ? ( + list ? ( + <Trans>Edit User List</Trans> + ) : ( + <Trans>New User List</Trans> + ) + ) : list ? ( + <Trans>Edit Moderation List</Trans> + ) : ( + <Trans>New Moderation List</Trans> + )} </Text> {error !== '' && ( <View style={styles.errorContainer}> @@ -195,14 +270,18 @@ 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]} placeholder={ - isCurateList ? 'e.g. Great Posters' : 'e.g. Spammers' + isCurateList + ? _(msg`e.g. Great Posters`) + : _(msg`e.g. Spammers`) } placeholderTextColor={colors.gray4} value={name} @@ -214,22 +293,30 @@ 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]} placeholder={ isCurateList - ? 'e.g. The posters who never miss.' - : 'e.g. Users that repeatedly reply with ads.' + ? _(msg`e.g. The posters who never miss.`) + : _(msg`e.g. Users that repeatedly reply with ads.`) } 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="" @@ -243,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`)} @@ -252,9 +340,9 @@ 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>Save</Trans> + <Trans context="action">Save</Trans> </Text> </LinearGradient> </TouchableOpacity> @@ -269,7 +357,7 @@ export function Component({ onAccessibilityEscape={onPressCancel}> <View style={[styles.btn]}> <Text style={[s.black, s.bold, pal.text]}> - <Trans>Cancel</Trans> + <Trans context="action">Cancel</Trans> </Text> </View> </TouchableOpacity> @@ -286,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 ee16d46b3..945d7bc89 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -62,7 +62,7 @@ export function Component({}: {}) { password, token, }) - Toast.show('Your account has been deleted') + Toast.show(_(msg`Your account has been deleted`)) resetToTab('HomeTab') removeAccount(currentAccount) clearCurrentAccount() @@ -125,7 +125,9 @@ export function Component({}: {}) { onPress={onPressSendEmail} accessibilityRole="button" accessibilityLabel={_(msg`Send email`)} - accessibilityHint="Sends email with confirmation code for account deletion"> + accessibilityHint={_( + msg`Sends email with confirmation code for account deletion`, + )}> <LinearGradient colors={[ gradients.blueLight.start, @@ -135,7 +137,7 @@ export function Component({}: {}) { end={{x: 1, y: 1}} style={[styles.btn]}> <Text type="button-lg" style={[s.white, s.bold]}> - <Trans>Send Email</Trans> + <Trans context="action">Send Email</Trans> </Text> </LinearGradient> </TouchableOpacity> @@ -147,7 +149,7 @@ export function Component({}: {}) { accessibilityHint="" onAccessibilityEscape={onCancel}> <Text type="button-lg" style={pal.textLight}> - <Trans>Cancel</Trans> + <Trans context="action">Cancel</Trans> </Text> </TouchableOpacity> </> @@ -158,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 @@ -174,9 +176,14 @@ export function Component({}: {}) { onChangeText={setConfirmCode} accessibilityLabelledBy="confirmationCode" accessibilityLabel={_(msg`Confirmation code`)} - accessibilityHint="Input confirmation code for account deletion" + accessibilityHint={_( + 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 @@ -189,7 +196,7 @@ export function Component({}: {}) { onChangeText={setPassword} accessibilityLabelledBy="password" accessibilityLabel={_(msg`Password`)} - accessibilityHint="Input password for account deletion" + accessibilityHint={_(msg`Input password for account deletion`)} /> {error ? ( <View style={styles.mt20}> @@ -220,7 +227,7 @@ export function Component({}: {}) { accessibilityHint="Exits account deletion process" onAccessibilityEscape={onCancel}> <Text type="button-lg" style={pal.textLight}> - <Trans>Cancel</Trans> + <Trans context="action">Cancel</Trans> </Text> </TouchableOpacity> </> diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx index 753907472..3b35ffee2 100644 --- a/src/view/com/modals/EditImage.tsx +++ b/src/view/com/modals/EditImage.tsx @@ -112,16 +112,16 @@ export const Component = observer(function EditImageImpl({ // }, { name: 'flip' as const, - label: 'Flip horizontal', + label: _(msg`Flip horizontal`), onPress: onFlipHorizontal, }, { name: 'flip' as const, - label: 'Flip vertically', + label: _(msg`Flip vertically`), onPress: onFlipVertical, }, ], - [onFlipHorizontal, onFlipVertical], + [onFlipHorizontal, onFlipVertical, _], ) useEffect(() => { @@ -284,7 +284,7 @@ export const Component = observer(function EditImageImpl({ size={label?.startsWith('Flip') ? 22 : 24} style={[ pal.text, - label === 'Flip vertically' + label === _(msg`Flip vertically`) ? styles.flipVertical : undefined, ]} @@ -330,7 +330,7 @@ export const Component = observer(function EditImageImpl({ end={{x: 1, y: 1}} style={[styles.btn]}> <Text type="xl-medium" style={s.white}> - <Trans>Done</Trans> + <Trans context="action">Done</Trans> </Text> </LinearGradient> </Pressable> diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index e044f8c0e..dd8ac9ae7 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -125,7 +125,7 @@ export function Component({ newUserAvatar, newUserBanner, }) - Toast.show('Profile updated') + Toast.show(_(msg`Profile updated`)) onUpdate?.() closeModal() } catch (e: any) { @@ -142,6 +142,7 @@ export function Component({ newUserAvatar, newUserBanner, setImageError, + _, ]) return ( @@ -181,7 +182,7 @@ export function Component({ <TextInput testID="editProfileDisplayNameInput" style={[styles.textInput, pal.border, pal.text]} - placeholder="e.g. Alice Roberts" + placeholder={_(msg`e.g. Alice Roberts`)} placeholderTextColor={colors.gray4} value={displayName} onChangeText={v => @@ -189,7 +190,7 @@ export function Component({ } accessible={true} accessibilityLabel={_(msg`Display name`)} - accessibilityHint="Edit your display name" + accessibilityHint={_(msg`Edit your display name`)} /> </View> <View style={s.pb10}> @@ -199,7 +200,7 @@ export function Component({ <TextInput testID="editProfileDescriptionInput" style={[styles.textArea, pal.border, pal.text]} - placeholder="e.g. Artist, dog-lover, and avid reader." + placeholder={_(msg`e.g. Artist, dog-lover, and avid reader.`)} placeholderTextColor={colors.gray4} keyboardAppearance={theme.colorScheme} multiline @@ -207,7 +208,7 @@ export function Component({ onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} accessible={true} accessibilityLabel={_(msg`Description`)} - accessibilityHint="Edit your profile description" + accessibilityHint={_(msg`Edit your profile description`)} /> </View> {updateMutation.isPending ? ( @@ -221,7 +222,7 @@ export function Component({ onPress={onPressSave} accessibilityRole="button" accessibilityLabel={_(msg`Save`)} - accessibilityHint="Saves any changes to your profile"> + accessibilityHint={_(msg`Saves any changes to your profile`)}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/modals/EmbedConsent.tsx b/src/view/com/modals/EmbedConsent.tsx new file mode 100644 index 000000000..04104c52e --- /dev/null +++ b/src/view/com/modals/EmbedConsent.tsx @@ -0,0 +1,153 @@ +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import {s, colors, gradients} from 'lib/styles' +import {Text} from '../util/text/Text' +import {ScrollView} from './util' +import {usePalette} from 'lib/hooks/usePalette' +import { + EmbedPlayerSource, + embedPlayerSources, + externalEmbedLabels, +} from '#/lib/strings/embed-player' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useSetExternalEmbedPref} from '#/state/preferences/external-embeds-prefs' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' + +export const snapPoints = [450] + +export function Component({ + onAccept, + source, +}: { + onAccept: () => void + source: EmbedPlayerSource +}) { + const pal = usePalette('default') + const {closeModal} = useModalControls() + const {_} = useLingui() + const setExternalEmbedPref = useSetExternalEmbedPref() + const {isMobile} = useWebMediaQueries() + + const onShowAllPress = React.useCallback(() => { + for (const key of embedPlayerSources) { + setExternalEmbedPref(key, 'show') + } + onAccept() + closeModal() + }, [closeModal, onAccept, setExternalEmbedPref]) + + const onShowPress = React.useCallback(() => { + setExternalEmbedPref(source, 'show') + onAccept() + closeModal() + }, [closeModal, onAccept, setExternalEmbedPref, source]) + + const onHidePress = React.useCallback(() => { + setExternalEmbedPref(source, 'hide') + closeModal() + }, [closeModal, setExternalEmbedPref, source]) + + return ( + <ScrollView + testID="embedConsentModal" + style={[ + s.flex1, + pal.view, + isMobile + ? {paddingHorizontal: 20, paddingTop: 10} + : {paddingHorizontal: 30}, + ]}> + <Text style={[pal.text, styles.title]}> + <Trans>External Media</Trans> + </Text> + + <Text style={pal.text}> + <Trans> + This content is hosted by {externalEmbedLabels[source]}. Do you want + to enable external media? + </Trans> + </Text> + <View style={[s.mt10]} /> + <Text style={pal.textLight}> + <Trans> + External media may allow websites to collect information about you and + your device. No information is sent or requested until you press the + "play" button. + </Trans> + </Text> + <View style={[s.mt20]} /> + <TouchableOpacity + testID="enableAllBtn" + onPress={onShowAllPress} + accessibilityRole="button" + accessibilityLabel={_( + msg`Show embeds from ${externalEmbedLabels[source]}`, + )} + accessibilityHint="" + onAccessibilityEscape={closeModal}> + <LinearGradient + colors={[gradients.blueLight.start, gradients.blueLight.end]} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[styles.btn]}> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Enable External Media</Trans> + </Text> + </LinearGradient> + </TouchableOpacity> + <View style={[s.mt10]} /> + <TouchableOpacity + testID="enableSourceBtn" + onPress={onShowPress} + accessibilityRole="button" + accessibilityLabel={_( + msg`Never load embeds from ${externalEmbedLabels[source]}`, + )} + accessibilityHint="" + onAccessibilityEscape={closeModal}> + <View style={[styles.btn, pal.btn]}> + <Text style={[pal.text, s.bold, s.f18]}> + <Trans>Enable {externalEmbedLabels[source]} only</Trans> + </Text> + </View> + </TouchableOpacity> + <View style={[s.mt10]} /> + <TouchableOpacity + testID="disableSourceBtn" + onPress={onHidePress} + accessibilityRole="button" + accessibilityLabel={_( + msg`Never load embeds from ${externalEmbedLabels[source]}`, + )} + accessibilityHint="" + onAccessibilityEscape={closeModal}> + <View style={[styles.btn, pal.btn]}> + <Text style={[pal.text, s.bold, s.f18]}> + <Trans>No thanks</Trans> + </Text> + </View> + </TouchableOpacity> + </ScrollView> + ) +} + +const styles = StyleSheet.create({ + title: { + textAlign: 'center', + fontWeight: 'bold', + fontSize: 24, + marginBottom: 12, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + borderRadius: 32, + padding: 14, + backgroundColor: colors.gray1, + }, +}) 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/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx index 0ebb545cf..c0318df01 100644 --- a/src/view/com/modals/InviteCodes.tsx +++ b/src/view/com/modals/InviteCodes.tsx @@ -18,7 +18,7 @@ import {ScrollView} from './util' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {Trans} from '@lingui/macro' +import {Trans, msg} from '@lingui/macro' import {cleanError} from 'lib/strings/errors' import {useModalControls} from '#/state/modals' import {useInvitesState, useInvitesAPI} from '#/state/invites' @@ -30,6 +30,7 @@ import { useInviteCodesQuery, InviteCodesQueryResponse, } from '#/state/queries/invites' +import {useLingui} from '@lingui/react' export const snapPoints = ['70%'] @@ -49,6 +50,7 @@ export function Component() { export function Inner({invites}: {invites: InviteCodesQueryResponse}) { const pal = usePalette('default') + const {_} = useLingui() const {closeModal} = useModalControls() const {isTabletOrDesktop} = useWebMediaQueries() @@ -75,7 +77,7 @@ export function Inner({invites}: {invites: InviteCodesQueryResponse}) { ]}> <Button type="primary" - label="Done" + label={_(msg`Done`)} style={styles.btn} labelStyle={styles.btnLabel} onPress={onClose} @@ -118,7 +120,7 @@ export function Inner({invites}: {invites: InviteCodesQueryResponse}) { <Button testID="closeBtn" type="primary" - label="Done" + label={_(msg`Done`)} style={styles.btn} labelStyle={styles.btnLabel} onPress={onClose} @@ -140,15 +142,16 @@ function InviteCode({ invites: InviteCodesQueryResponse }) { const pal = usePalette('default') + const {_} = useLingui() const invitesState = useInvitesState() const {setInviteCopied} = useInvitesAPI() const uses = invite.uses const onPress = React.useCallback(() => { Clipboard.setString(invite.code) - Toast.show('Copied to clipboard') + Toast.show(_(msg`Copied to clipboard`)) setInviteCopied(invite.code) - }, [setInviteCopied, invite]) + }, [setInviteCopied, invite, _]) return ( <View @@ -163,10 +166,10 @@ function InviteCode({ accessibilityRole="button" accessibilityLabel={ invites.available.length === 1 - ? 'Invite codes: 1 available' - : `Invite codes: ${invites.available.length} available` + ? _(msg`Invite codes: 1 available`) + : _(msg`Invite codes: ${invites.available.length} available`) } - accessibilityHint="Opens list of invite codes"> + accessibilityHint={_(msg`Opens list of invite codes`)}> <Text testID={`${testID}-code`} type={used ? 'md' : 'md-bold'} 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/ListAddRemoveUsers.tsx b/src/view/com/modals/ListAddRemoveUsers.tsx index 14e16d6bf..27c33f806 100644 --- a/src/view/com/modals/ListAddRemoveUsers.tsx +++ b/src/view/com/modals/ListAddRemoveUsers.tsx @@ -67,7 +67,7 @@ export function Component({ <TextInput testID="searchInput" style={[styles.searchInput, pal.border, pal.text]} - placeholder="Search for users" + placeholder={_(msg`Search for users`)} placeholderTextColor={pal.colors.textLight} value={query} onChangeText={setQuery} @@ -85,7 +85,7 @@ export function Component({ onPress={onPressCancelSearch} accessibilityRole="button" accessibilityLabel={_(msg`Cancel search`)} - accessibilityHint="Exits inputting search query" + accessibilityHint={_(msg`Exits inputting search query`)} onAccessibilityEscape={onPressCancelSearch} hitSlop={HITSLOP_20}> <FontAwesomeIcon @@ -141,7 +141,7 @@ export function Component({ }} accessibilityLabel={_(msg`Done`)} accessibilityHint="" - label="Done" + label={_(msg({message: 'Done', context: 'action'}))} labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 2aac20dac..7f814d971 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -38,6 +38,8 @@ import * as VerifyEmailModal from './VerifyEmail' 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 @@ -176,6 +178,12 @@ export function ModalsContainer() { } else if (activeModal?.name === 'link-warning') { snapPoints = LinkWarningModal.snapPoints element = <LinkWarningModal.Component {...activeModal} /> + } 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 12138f54d..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' @@ -34,9 +35,11 @@ import * as BirthDateSettingsModal from './BirthDateSettings' import * as VerifyEmailModal from './VerifyEmail' import * as ChangeEmailModal from './ChangeEmail' import * as LinkWarningModal from './LinkWarning' +import * as EmbedConsentModal from './EmbedConsent' export function ModalsContainer() { const {isModalActive, activeModals} = useModals() + useWebBodyScrollLock(isModalActive) if (!isModalActive) { return null @@ -62,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() @@ -129,6 +136,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <ChangeEmailModal.Component /> } else if (modal.name === 'link-warning') { element = <LinkWarningModal.Component {...modal} /> + } else if (modal.name === 'embed-consent') { + element = <EmbedConsentModal.Component {...modal} /> } else { return null } @@ -159,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/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx index c117023d4..ba7f76db1 100644 --- a/src/view/com/modals/ModerationDetails.tsx +++ b/src/view/com/modals/ModerationDetails.tsx @@ -10,6 +10,8 @@ import {isWeb} from 'platform/detection' import {listUriToHref} from 'lib/strings/url-helpers' import {Button} from '../util/forms/Button' import {useModalControls} from '#/state/modals' +import {useLingui} from '@lingui/react' +import {Trans, msg} from '@lingui/macro' export const snapPoints = [300] @@ -23,19 +25,21 @@ export function Component({ const {closeModal} = useModalControls() const {isMobile} = useWebMediaQueries() const pal = usePalette('default') + const {_} = useLingui() let name let description if (!moderation.cause) { - name = 'Content Warning' - description = - 'Moderator has chosen to set a general warning on the content.' + name = _(msg`Content Warning`) + description = _( + msg`Moderator has chosen to set a general warning on the content.`, + ) } else if (moderation.cause.type === 'blocking') { if (moderation.cause.source.type === 'list') { const list = moderation.cause.source.list - name = 'User Blocked by List' + name = _(msg`User Blocked by List`) description = ( - <> + <Trans> This user is included in the{' '} <TextLink type="2xl" @@ -44,25 +48,30 @@ export function Component({ style={pal.link} />{' '} list which you have blocked. - </> + </Trans> ) } else { - name = 'User Blocked' - description = 'You have blocked this user. You cannot view their content.' + name = _(msg`User Blocked`) + description = _( + msg`You have blocked this user. You cannot view their content.`, + ) } } else if (moderation.cause.type === 'blocked-by') { - name = 'User Blocks You' - description = 'This user has blocked you. You cannot view their content.' + name = _(msg`User Blocks You`) + description = _( + msg`This user has blocked you. You cannot view their content.`, + ) } else if (moderation.cause.type === 'block-other') { - name = 'Content Not Available' - description = - 'This content is not available because one of the users involved has blocked the other.' + name = _(msg`Content Not Available`) + description = _( + msg`This content is not available because one of the users involved has blocked the other.`, + ) } else if (moderation.cause.type === 'muted') { if (moderation.cause.source.type === 'list') { const list = moderation.cause.source.list - name = <>Account Muted by List</> + name = _(msg`Account Muted by List`) description = ( - <> + <Trans> This user is included the{' '} <TextLink type="2xl" @@ -71,11 +80,11 @@ export function Component({ style={pal.link} />{' '} list which you have muted. - </> + </Trans> ) } else { - name = 'Account Muted' - description = 'You have muted this user.' + name = _(msg`Account Muted`) + description = _(msg`You have muted this user.`) } } else { name = moderation.cause.labelDef.strings[context].en.name diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx index edfbf6a82..77e68db70 100644 --- a/src/view/com/modals/ProfilePreview.tsx +++ b/src/view/com/modals/ProfilePreview.tsx @@ -14,11 +14,14 @@ import {ErrorScreen} from '../util/error/ErrorScreen' import {CenteredView} from '../util/Views' import {cleanError} from '#/lib/strings/errors' import {useProfileShadow} from '#/state/cache/profile-shadow' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export const snapPoints = [520, '100%'] export function Component({did}: {did: string}) { const pal = usePalette('default') + const {_} = useLingui() const moderationOpts = useModerationOpts() const { data: profile, @@ -43,7 +46,7 @@ export function Component({did}: {did: string}) { if (profileError) { return ( <ErrorScreen - title="Oops!" + title={_(msg`Oops!`)} message={cleanError(profileError)} onPressTryAgain={refetchProfile} /> @@ -55,8 +58,8 @@ export function Component({did}: {did: string}) { // should never happen return ( <ErrorScreen - title="Oops!" - message="Something went wrong and we're not sure what." + title={_(msg`Oops!`)} + message={_(msg`Something went wrong and we're not sure what.`)} onPressTryAgain={refetchProfile} /> ) @@ -104,7 +107,7 @@ function ComponentLoaded({ <> <InfoCircleIcon size={21} style={pal.textLight} /> <ThemedText type="xl" fg="light"> - Swipe up to see more + <Trans>Swipe up to see more</Trans> </ThemedText> </> )} diff --git a/src/view/com/modals/Repost.tsx b/src/view/com/modals/Repost.tsx index a72da29b4..6e4881adc 100644 --- a/src/view/com/modals/Repost.tsx +++ b/src/view/com/modals/Repost.tsx @@ -37,11 +37,23 @@ export function Component({ style={[styles.actionBtn]} onPress={onRepost} accessibilityRole="button" - accessibilityLabel={isReposted ? 'Undo repost' : 'Repost'} - accessibilityHint={isReposted ? 'Remove repost' : 'Repost '}> + accessibilityLabel={ + isReposted + ? _(msg`Undo repost`) + : _(msg({message: `Repost`, context: 'action'})) + } + accessibilityHint={ + isReposted + ? _(msg`Remove repost`) + : _(msg({message: `Repost`, context: 'action'})) + }> <RepostIcon strokeWidth={2} size={24} style={s.blue3} /> <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}> - <Trans>{!isReposted ? 'Repost' : 'Undo repost'}</Trans> + {!isReposted ? ( + <Trans context="action">Repost</Trans> + ) : ( + <Trans>Undo repost</Trans> + )} </Text> </TouchableOpacity> <TouchableOpacity @@ -49,11 +61,13 @@ export function Component({ style={[styles.actionBtn]} onPress={onQuote} accessibilityRole="button" - accessibilityLabel={_(msg`Quote post`)} + accessibilityLabel={_( + msg({message: `Quote post`, context: 'action'}), + )} accessibilityHint=""> <FontAwesomeIcon icon="quote-left" size={24} style={s.blue3} /> <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}> - <Trans>Quote Post</Trans> + <Trans context="action">Quote Post</Trans> </Text> </TouchableOpacity> </View> diff --git a/src/view/com/modals/SelfLabel.tsx b/src/view/com/modals/SelfLabel.tsx index 092dd2d32..779a9e71b 100644 --- a/src/view/com/modals/SelfLabel.tsx +++ b/src/view/com/modals/SelfLabel.tsx @@ -92,7 +92,7 @@ export function Component({ testID="sexualLabelBtn" selected={selected.includes('sexual')} left - label="Suggestive" + label={_(msg`Suggestive`)} onSelect={() => toggleAdultLabel('sexual')} accessibilityHint="" style={s.flex1} @@ -100,7 +100,7 @@ export function Component({ <SelectableBtn testID="nudityLabelBtn" selected={selected.includes('nudity')} - label="Nudity" + label={_(msg`Nudity`)} onSelect={() => toggleAdultLabel('nudity')} accessibilityHint="" style={s.flex1} @@ -108,7 +108,7 @@ export function Component({ <SelectableBtn testID="pornLabelBtn" selected={selected.includes('porn')} - label="Porn" + label={_(msg`Porn`)} right onSelect={() => toggleAdultLabel('porn')} accessibilityHint="" @@ -154,7 +154,7 @@ export function Component({ accessibilityLabel={_(msg`Confirm`)} accessibilityHint=""> <Text style={[s.white, s.bold, s.f18]}> - <Trans>Done</Trans> + <Trans context="action">Done</Trans> </Text> </TouchableOpacity> </View> diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx index b30293859..550dffa1c 100644 --- a/src/view/com/modals/ServerInput.tsx +++ b/src/view/com/modals/ServerInput.tsx @@ -101,7 +101,9 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { onChangeText={setCustomUrl} accessibilityLabel={_(msg`Custom domain`)} // TODO: Simplify this wording further to be understandable by everyone - accessibilityHint="Use your domain as your Bluesky client service provider" + accessibilityHint={_( + msg`Use your domain as your Bluesky client service provider`, + )} /> <TouchableOpacity testID="customServerSelectBtn" @@ -110,7 +112,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { accessibilityRole="button" accessibilityLabel={`Confirm service. ${ customUrl === '' - ? 'Button disabled. Input custom domain to proceed.' + ? _(msg`Button disabled. Input custom domain to proceed.`) : '' }`} accessibilityHint="" diff --git a/src/view/com/modals/SwitchAccount.tsx b/src/view/com/modals/SwitchAccount.tsx index 37691e717..c034c4b52 100644 --- a/src/view/com/modals/SwitchAccount.tsx +++ b/src/view/com/modals/SwitchAccount.tsx @@ -62,7 +62,9 @@ function SwitchAccountCard({account}: {account: SessionAccount}) { onPress={isSwitchingAccounts ? undefined : onPressSignout} accessibilityRole="button" accessibilityLabel={_(msg`Sign out`)} - accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}> + accessibilityHint={_( + msg`Signs ${profile?.displayName} out of Bluesky`, + )}> <Text type="lg" style={pal.link}> <Trans>Sign out</Trans> </Text> @@ -92,8 +94,8 @@ function SwitchAccountCard({account}: {account: SessionAccount}) { isSwitchingAccounts ? undefined : () => onPressSwitchAccount(account) } accessibilityRole="button" - accessibilityLabel={`Switch to ${account.handle}`} - accessibilityHint="Switches the account you are logged in to"> + accessibilityLabel={_(msg`Switch to ${account.handle}`)} + accessibilityHint={_(msg`Switches the account you are logged in to`)}> {contents} </TouchableOpacity> ) diff --git a/src/view/com/modals/Threadgate.tsx b/src/view/com/modals/Threadgate.tsx index 0deef185b..0e49fc2f3 100644 --- a/src/view/com/modals/Threadgate.tsx +++ b/src/view/com/modals/Threadgate.tsx @@ -126,10 +126,10 @@ export function Component({ }} style={styles.btn} accessibilityRole="button" - accessibilityLabel={_(msg`Done`)} + accessibilityLabel={_(msg({message: `Done`, context: 'action'}))} accessibilityHint=""> <Text style={[s.white, s.bold, s.f18]}> - <Trans>Done</Trans> + <Trans context="action">Done</Trans> </Text> </TouchableOpacity> </View> diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx index c51f862cc..23adbe1a8 100644 --- a/src/view/com/modals/UserAddRemoveLists.tsx +++ b/src/view/com/modals/UserAddRemoveLists.tsx @@ -76,10 +76,10 @@ export function Component({ type="default" onPress={onPressDone} style={styles.footerBtn} - accessibilityLabel={_(msg`Done`)} + accessibilityLabel={_(msg({message: `Done`, context: 'action'}))} accessibilityHint="" onAccessibilityEscape={onPressDone} - label="Done" + label={_(msg({message: `Done`, context: 'action'}))} /> </View> </View> @@ -175,12 +175,22 @@ function ListItem({ {sanitizeDisplayName(list.name)} </Text> <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {list.purpose === 'app.bsky.graph.defs#curatelist' && 'User list '} - {list.purpose === 'app.bsky.graph.defs#modlist' && 'Moderation list '} - by{' '} - {list.creator.did === currentAccount?.did - ? 'you' - : sanitizeHandle(list.creator.handle, '@')} + {list.purpose === 'app.bsky.graph.defs#curatelist' && + (list.creator.did === currentAccount?.did ? ( + <Trans>User list by you</Trans> + ) : ( + <Trans> + User list by {sanitizeHandle(list.creator.handle, '@')} + </Trans> + ))} + {list.purpose === 'app.bsky.graph.defs#modlist' && + (list.creator.did === currentAccount?.did ? ( + <Trans>Moderation list by you</Trans> + ) : ( + <Trans> + Moderation list by {sanitizeHandle(list.creator.handle, '@')} + </Trans> + ))} </Text> </View> <View> diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx index 4f2b1aadf..30a57afc5 100644 --- a/src/view/com/modals/VerifyEmail.tsx +++ b/src/view/com/modals/VerifyEmail.tsx @@ -75,7 +75,7 @@ export function Component({showReminder}: {showReminder?: boolean}) { token: confirmationCode.trim(), }) updateCurrentAccount({emailConfirmed: true}) - Toast.show('Email verified') + Toast.show(_(msg`Email verified`)) closeModal() } catch (e) { setError(cleanError(String(e))) @@ -97,9 +97,15 @@ export function Component({showReminder}: {showReminder?: boolean}) { {stage === Stages.Reminder && <ReminderIllustration />} <View style={styles.titleSection}> <Text type="title-lg" style={[pal.text, styles.title]}> - {stage === Stages.Reminder ? 'Please Verify Your Email' : ''} - {stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''} - {stage === Stages.Email ? 'Verify Your Email' : ''} + {stage === Stages.Reminder ? ( + <Trans>Please Verify Your Email</Trans> + ) : stage === Stages.Email ? ( + <Trans>Verify Your Email</Trans> + ) : stage === Stages.ConfirmCode ? ( + <Trans>Enter Confirmation Code</Trans> + ) : ( + '' + )} </Text> </View> @@ -133,7 +139,7 @@ export function Component({showReminder}: {showReminder?: boolean}) { size={16} /> <Text type="xl-medium" style={[pal.text, s.flex1, {minWidth: 0}]}> - {currentAccount?.email || '(no email)'} + {currentAccount?.email || _(msg`(no email)`)} </Text> </View> <Pressable @@ -182,7 +188,7 @@ export function Component({showReminder}: {showReminder?: boolean}) { onPress={() => setStage(Stages.Email)} accessibilityLabel={_(msg`Get Started`)} accessibilityHint="" - label="Get Started" + label={_(msg`Get Started`)} labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> @@ -195,7 +201,7 @@ export function Component({showReminder}: {showReminder?: boolean}) { onPress={onSendEmail} accessibilityLabel={_(msg`Send Confirmation Email`)} accessibilityHint="" - label="Send Confirmation Email" + label={_(msg`Send Confirmation Email`)} labelContainerStyle={{ justifyContent: 'center', padding: 4, @@ -207,7 +213,7 @@ export function Component({showReminder}: {showReminder?: boolean}) { type="default" accessibilityLabel={_(msg`I have a code`)} accessibilityHint="" - label="I have a confirmation code" + label={_(msg`I have a confirmation code`)} labelContainerStyle={{ justifyContent: 'center', padding: 4, @@ -224,7 +230,7 @@ export function Component({showReminder}: {showReminder?: boolean}) { onPress={onConfirm} accessibilityLabel={_(msg`Confirm`)} accessibilityHint="" - label="Confirm" + label={_(msg`Confirm`)} labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> @@ -236,10 +242,16 @@ export function Component({showReminder}: {showReminder?: boolean}) { closeModal() }} accessibilityLabel={ - stage === Stages.Reminder ? 'Not right now' : 'Cancel' + stage === Stages.Reminder + ? _(msg`Not right now`) + : _(msg`Cancel`) } accessibilityHint="" - label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'} + label={ + stage === Stages.Reminder + ? _(msg`Not right now`) + : _(msg`Cancel`) + } labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> diff --git a/src/view/com/modals/Waitlist.tsx b/src/view/com/modals/Waitlist.tsx index a31545c0a..263dd27a2 100644 --- a/src/view/com/modals/Waitlist.tsx +++ b/src/view/com/modals/Waitlist.tsx @@ -48,7 +48,7 @@ export function Component({}: {}) { } else { setError( resBody.error || - 'Something went wrong. Check your email and try again.', + _(msg`Something went wrong. Check your email and try again.`), ) } } catch (e: any) { @@ -75,7 +75,7 @@ export function Component({}: {}) { </Text> <TextInput style={[styles.textInput, pal.borderDark, pal.text, s.mb10, s.mt10]} - placeholder="Enter your email" + placeholder={_(msg`Enter your email`)} placeholderTextColor={pal.textLight.color} autoCapitalize="none" autoCorrect={false} @@ -86,7 +86,9 @@ export function Component({}: {}) { enterKeyHint="done" accessible={true} accessibilityLabel={_(msg`Email`)} - accessibilityHint="Input your email to get on the Bluesky waitlist" + accessibilityHint={_( + msg`Input your email to get on the Bluesky waitlist`, + )} /> {error ? ( <View style={s.mt10}> @@ -114,7 +116,9 @@ export function Component({}: {}) { <TouchableOpacity onPress={onPressSignup} accessibilityRole="button" - accessibilityHint={`Confirms signing up ${email} to the waitlist`}> + accessibilityHint={_( + msg`Confirms signing up ${email} to the waitlist`, + )}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} @@ -130,7 +134,9 @@ export function Component({}: {}) { onPress={onCancel} accessibilityRole="button" accessibilityLabel={_(msg`Cancel waitlist signup`)} - accessibilityHint={`Exits signing up for waitlist with ${email}`} + accessibilityHint={_( + msg`Exits signing up for waitlist with ${email}`, + )} onAccessibilityEscape={onCancel}> <Text type="button-lg" style={pal.textLight}> <Trans>Cancel</Trans> diff --git a/src/view/com/modals/report/InputIssueDetails.tsx b/src/view/com/modals/report/InputIssueDetails.tsx index 2f701b799..2bc86f75e 100644 --- a/src/view/com/modals/report/InputIssueDetails.tsx +++ b/src/view/com/modals/report/InputIssueDetails.tsx @@ -42,7 +42,8 @@ export function InputIssueDetails({ accessibilityHint="Add more details to your report"> <FontAwesomeIcon size={18} icon="angle-left" style={[pal.link]} /> <Text style={[pal.text, s.f18, pal.link]}> - <Trans> Back</Trans> + {' '} + <Trans>Back</Trans> </Text> </TouchableOpacity> <View style={[pal.btn, styles.detailsInputContainer]}> diff --git a/src/view/com/modals/report/Modal.tsx b/src/view/com/modals/report/Modal.tsx index 60c3f06b7..afd0d417d 100644 --- a/src/view/com/modals/report/Modal.tsx +++ b/src/view/com/modals/report/Modal.tsx @@ -44,9 +44,9 @@ export function Component(content: ReportComponentProps) { const {isMobile} = useWebMediaQueries() const [isProcessing, setIsProcessing] = useState(false) const [showDetailsInput, setShowDetailsInput] = useState(false) - const [error, setError] = useState<string>() - const [issue, setIssue] = useState<string>() - const [details, setDetails] = useState<string>() + const [error, setError] = useState<string>('') + const [issue, setIssue] = useState<string>('') + const [details, setDetails] = useState<string>('') const isAccountReport = 'did' in content const subjectKey = isAccountReport ? content.did : content.uri const atUri = useMemo( diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index a99fe2c1d..2088acbac 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -13,6 +13,8 @@ import {logger} from '#/logger' import {cleanError} from '#/lib/strings/errors' import {useModerationOpts} from '#/state/queries/preferences' import {List, ListRef} from '../util/List' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} @@ -31,6 +33,7 @@ export function Feed({ }) { const [isPTRing, setIsPTRing] = React.useState(false) + const {_} = useLingui() const moderationOpts = useModerationOpts() const {checkUnread} = useUnreadNotificationsApi() const { @@ -101,14 +104,16 @@ export function Feed({ return ( <EmptyState icon="bell" - message="No notifications yet!" + message={_(msg`No notifications yet!`)} style={styles.emptyState} /> ) } else if (item === LOAD_MORE_ERROR_ITEM) { return ( <LoadMoreRetryBtn - label="There was an issue fetching notifications. Tap here to try again." + label={_( + msg`There was an issue fetching notifications. Tap here to try again.`, + )} onPress={onPressRetryLoadMore} /> ) @@ -117,7 +122,7 @@ export function Feed({ } return <FeedItem item={item} moderationOpts={moderationOpts!} /> }, - [onPressRetryLoadMore, moderationOpts], + [onPressRetryLoadMore, moderationOpts, _], ) const FeedFooter = React.useCallback( diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 24b7e4fb6..0dfac2a83 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -65,6 +65,7 @@ let FeedItem = ({ moderationOpts: ModerationOpts }): React.ReactNode => { const pal = usePalette('default') + const {_} = useLingui() const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false) const itemHref = useMemo(() => { if (item.type === 'post-like' || item.type === 'repost') { @@ -151,24 +152,24 @@ let FeedItem = ({ let icon: Props['icon'] | 'HeartIconSolid' let iconStyle: Props['style'] = [] if (item.type === 'post-like') { - action = 'liked your post' + action = _(msg`liked your post`) icon = 'HeartIconSolid' iconStyle = [ s.likeColor as FontAwesomeIconStyle, {position: 'relative', top: -4}, ] } else if (item.type === 'repost') { - action = 'reposted your post' + action = _(msg`reposted your post`) icon = 'retweet' iconStyle = [s.green3 as FontAwesomeIconStyle] } else if (item.type === 'follow') { - action = 'followed you' + action = _(msg`followed you`) icon = 'user-plus' iconStyle = [s.blue3 as FontAwesomeIconStyle] } else if (item.type === 'feedgen-like') { - action = `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, @@ -314,14 +315,16 @@ function CondensedAuthorsList({ onPress={onToggleAuthorsExpanded} accessibilityRole="button" accessibilityLabel={_(msg`Hide user list`)} - accessibilityHint="Collapses list of users for a given notification"> + accessibilityHint={_( + msg`Collapses list of users for a given notification`, + )}> <FontAwesomeIcon icon="angle-up" size={18} style={[styles.expandedAuthorsCloseBtnIcon, pal.text]} /> <Text type="sm-medium" style={pal.text}> - <Trans>Hide</Trans> + <Trans context="action">Hide</Trans> </Text> </TouchableOpacity> </View> @@ -343,7 +346,9 @@ function CondensedAuthorsList({ return ( <TouchableOpacity accessibilityLabel={_(msg`Show users`)} - accessibilityHint="Opens an expanded list of users in this notification" + accessibilityHint={_( + msg`Opens an expanded list of users in this notification`, + )} onPress={onToggleAuthorsExpanded}> <View style={styles.avis}> {authors.slice(0, MAX_AUTHORS).map(author => ( 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 024f9bfab..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,13 +73,15 @@ 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} accessibilityRole="button" accessibilityLabel={_(msg`Open navigation`)} - accessibilityHint="Access profile and other navigation links" + accessibilityHint={_( + msg`Access profile and other navigation links`, + )} hitSlop={HITSLOP_10}> <FontAwesomeIcon icon="bars" @@ -86,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" @@ -121,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 6cd1f3551..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) @@ -222,7 +262,11 @@ function PostThreadLoaded({ const renderItem = React.useCallback( ({item, index}: {item: YieldedItem; index: number}) => { if (item === TOP_COMPONENT) { - return isTablet ? <ViewHeader title={_(msg`Post`)} /> : null + return isTablet ? ( + <ViewHeader + title={_(msg({message: `Post`, context: 'description'}))} + /> + ) : null } else if (item === PARENT_SPINNER) { return ( <View style={styles.parentSpinner}> @@ -276,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, }} @@ -354,6 +400,7 @@ function PostThreadLoaded({ style={s.hContentRegion} // @ts-ignore our .web version only -prf desktopFixedHeight + removeClippedSubviews={isAndroid ? false : undefined} /> ) } @@ -393,7 +440,7 @@ function PostThreadBlocked() { style={[pal.link as FontAwesomeIconStyle, s.mr5]} size={14} /> - Back + <Trans context="action">Back</Trans> </Text> </TouchableOpacity> </View> @@ -464,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 } @@ -480,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 986fd70b2..a27ee0a58 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -158,6 +158,7 @@ let PostThreadItemLoaded = ({ onPostReply: () => void }): React.ReactNode => { const pal = usePalette('default') + const {_} = useLingui() const langPrefs = useLanguagePrefs() const {openComposer} = useComposerControls() const {currentAccount} = useSession() @@ -172,7 +173,7 @@ let PostThreadItemLoaded = ({ const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey) }, [post.uri, post.author]) - const itemTitle = `Post by ${post.author.handle}` + const itemTitle = _(msg`Post by ${post.author.handle}`) const authorHref = makeProfileLink(post.author) const authorTitle = post.author.handle const isAuthorMuted = post.author.viewer?.muted @@ -180,12 +181,12 @@ let PostThreadItemLoaded = ({ const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') }, [post.uri, post.author]) - const likesTitle = 'Likes on this post' + const likesTitle = _(msg`Likes on this post`) const repostsHref = React.useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') }, [post.uri, post.author]) - const repostsTitle = 'Reposts of this post' + const repostsTitle = _(msg`Reposts of this post`) const isModeratedPost = moderation.decisions.post.cause?.type === 'label' && moderation.decisions.post.cause.label.src !== currentAccount?.did @@ -214,6 +215,7 @@ let PostThreadItemLoaded = ({ displayName: post.author.displayName, avatar: post.author.avatar, }, + embed: post.embed, }, onPost: onPostReply, }) @@ -224,7 +226,7 @@ let PostThreadItemLoaded = ({ }, [setLimitLines]) if (!record) { - return <ErrorMessage message="Invalid or unsupported post record" /> + return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} /> } if (isHighlightedPost) { @@ -246,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}> @@ -334,6 +335,7 @@ let PostThreadItemLoaded = ({ postCid={post.cid} postUri={post.uri} record={record} + richText={richText} showAppealLabelItem={ post.author.did === currentAccount?.did && isModeratedPost } @@ -367,6 +369,7 @@ let PostThreadItemLoaded = ({ richText={richText} lineHeight={1.3} style={s.flex1} + selectable /> </View> ) : undefined} @@ -437,11 +440,12 @@ let PostThreadItemLoaded = ({ big post={post} record={record} + richText={richText} onPressReply={onPressReply} /> </View> </View> - </Link> + </View> <WhoCanReply post={post} /> </> ) @@ -562,7 +566,7 @@ let PostThreadItemLoaded = ({ ) : undefined} {limitLines ? ( <TextLink - text="Show More" + text={_(msg`Show More`)} style={pal.link} onPress={onPressShowMore} href="#" @@ -585,6 +589,7 @@ let PostThreadItemLoaded = ({ <PostCtrls post={post} record={record} + richText={richText} onPressReply={onPressReply} /> </View> @@ -701,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/post/Post.tsx b/src/view/com/post/Post.tsx index fca4171c3..f035c32ad 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -27,6 +27,8 @@ import {countLines} from 'lib/strings/helpers' import {useModerationOpts} from '#/state/queries/preferences' import {useComposerControls} from '#/state/shell/composer' import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export function Post({ post, @@ -95,6 +97,7 @@ function PostInner({ style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') + const {_} = useLingui() const {openComposer} = useComposerControls() const [limitLines, setLimitLines] = useState( () => countLines(richText?.text) >= MAX_POST_LINES, @@ -118,6 +121,7 @@ function PostInner({ displayName: post.author.displayName, avatar: post.author.avatar, }, + embed: post.embed, }, }) }, [openComposer, post, record]) @@ -158,13 +162,15 @@ function PostInner({ style={[pal.textLight, s.mr2]} lineHeight={1.2} numberOfLines={1}> - Reply to{' '} - <UserInfoText - type="sm" - did={replyAuthorDid} - attr="displayName" - style={[pal.textLight]} - /> + <Trans context="description"> + Reply to{' '} + <UserInfoText + type="sm" + did={replyAuthorDid} + attr="displayName" + style={[pal.textLight]} + /> + </Trans> </Text> </View> )} @@ -187,7 +193,7 @@ function PostInner({ ) : undefined} {limitLines ? ( <TextLink - text="Show More" + text={_(msg`Show More`)} style={pal.link} onPress={onPressShowMore} href="#" @@ -207,7 +213,12 @@ function PostInner({ </ContentHider> ) : null} </ContentHider> - <PostCtrls post={post} record={record} onPressReply={onPressReply} /> + <PostCtrls + post={post} + record={record} + richText={richText} + onPressReply={onPressReply} + /> </View> </View> </Link> diff --git a/src/view/com/posts/CustomFeedEmptyState.tsx b/src/view/com/posts/CustomFeedEmptyState.tsx index e83a94f03..62a10fd19 100644 --- a/src/view/com/posts/CustomFeedEmptyState.tsx +++ b/src/view/com/posts/CustomFeedEmptyState.tsx @@ -12,6 +12,7 @@ import {NavigationProp} from 'lib/routes/types' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {isWeb} from 'platform/detection' +import {Trans} from '@lingui/macro' export function CustomFeedEmptyState() { const pal = usePalette('default') @@ -33,15 +34,17 @@ export function CustomFeedEmptyState() { <MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} /> </View> <Text type="xl-medium" style={[s.textCenter, pal.text]}> - This feed is empty! You may need to follow more users or tune your - language settings. + <Trans> + This feed is empty! You may need to follow more users or tune your + language settings. + </Trans> </Text> <Button type="inverted" style={styles.emptyBtn} onPress={onPressFindAccounts}> <Text type="lg-medium" style={palInverted.text}> - Find accounts to follow + <Trans>Find accounts to follow</Trans> </Text> <FontAwesomeIcon icon="angle-right" 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 02a3537eb..04753fe6c 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -28,13 +28,18 @@ import {isWeb} from '#/platform/detection' import {listenPostCreated} from '#/state/events' 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__'} const ERROR_ITEM = {_reactKey: '__error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -const REFRESH_AFTER = STALE.HOURS.ONE +// DISABLED need to check if this is causing random feed refreshes -prf +// const REFRESH_AFTER = STALE.HOURS.ONE const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY let Feed = ({ @@ -44,6 +49,7 @@ let Feed = ({ style, enabled, pollInterval, + disablePoll, scrollElRef, onScrolledDownChange, onHasNew, @@ -61,6 +67,7 @@ let Feed = ({ style?: StyleProp<ViewStyle> enabled?: boolean pollInterval?: number + disablePoll?: boolean scrollElRef?: ListRef onHasNew?: (v: boolean) => void onScrolledDownChange?: (isScrolledDown: boolean) => void @@ -74,6 +81,7 @@ let Feed = ({ }): React.ReactNode => { const theme = useTheme() const {track} = useAnalytics() + const {_} = useLingui() const queryClient = useQueryClient() const {currentAccount} = useSession() const [isPTRing, setIsPTRing] = React.useState(false) @@ -104,7 +112,7 @@ let Feed = ({ ) const checkForNew = React.useCallback(async () => { - if (!data?.pages[0] || isFetching || !onHasNew || !enabled) { + if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) { return } try { @@ -114,7 +122,7 @@ let Feed = ({ } catch (e) { logger.error('Poll latest failed', {feed, error: String(e)}) } - }, [feed, data, isFetching, onHasNew, enabled]) + }, [feed, data, isFetching, onHasNew, enabled, disablePoll]) const myDid = currentAccount?.did || '' const onPostCreated = React.useCallback(() => { @@ -143,11 +151,12 @@ let Feed = ({ React.useEffect(() => { if (enabled) { const timeSinceFirstLoad = Date.now() - lastFetchRef.current - if (timeSinceFirstLoad > REFRESH_AFTER) { + // DISABLED need to check if this is causing random feed refreshes -prf + /*if (timeSinceFirstLoad > REFRESH_AFTER) { // do a full refresh scrollElRef?.current?.scrollToOffset({offset: 0, animated: false}) queryClient.resetQueries({queryKey: RQKEY(feed)}) - } else if ( + } else*/ if ( timeSinceFirstLoad > CHECK_LATEST_AFTER && checkForNewRef.current ) { @@ -250,16 +259,24 @@ let Feed = ({ } else if (item === LOAD_MORE_ERROR_ITEM) { return ( <LoadMoreRetryBtn - label="There was an issue fetching posts. Tap here to try again." + label={_( + msg`There was an issue fetching posts. Tap here to try again.`, + )} onPress={onPressRetryLoadMore} /> ) } 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} /> }, - [feed, error, onPressTryAgain, onPressRetryLoadMore, renderEmptyState], + [feed, error, onPressTryAgain, onPressRetryLoadMore, renderEmptyState, _], ) const shouldRenderEndOfFeed = diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx index aeac45980..48ed49bb1 100644 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ b/src/view/com/posts/FeedErrorMessage.tsx @@ -38,6 +38,7 @@ export function FeedErrorMessage({ error?: Error onPressTryAgain: () => void }) { + const {_: _l} = useLingui() const knownError = React.useMemo( () => detectKnownError(feedDesc, error), [feedDesc, error], @@ -60,7 +61,7 @@ export function FeedErrorMessage({ return ( <EmptyState icon="ban" - message="Posts hidden" + message={_l(msgLingui`Posts hidden`)} style={{paddingVertical: 40}} /> ) @@ -134,7 +135,9 @@ function FeedgenErrorMessage({ await removeFeed({uri}) } catch (err) { Toast.show( - 'There was an an issue removing this feed. Please check your internet connection and try again.', + _l( + msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`, + ), ) logger.error('Failed to remove feed', {error: err}) } @@ -160,20 +163,20 @@ function FeedgenErrorMessage({ {knownError === KnownError.FeedgenDoesNotExist && ( <Button type="inverted" - label="Remove feed" + label={_l(msgLingui`Remove feed`)} onPress={onRemoveFeed} /> )} <Button type="default-light" - label="View profile" + label={_l(msgLingui`View profile`)} onPress={onViewProfile} /> </View> ) } } - }, [knownError, onViewProfile, onRemoveFeed]) + }, [knownError, onViewProfile, onRemoveFeed, _l]) return ( <View @@ -191,7 +194,7 @@ function FeedgenErrorMessage({ {rawError?.message && ( <Text style={pal.textLight}> - <Trans>Message from server</Trans>: {rawError.message} + <Trans>Message from server: {rawError.message}</Trans> </Text> )} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 942d7bf71..225607ca9 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -35,6 +35,8 @@ import {useComposerControls} from '#/state/shell/composer' import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' import {FeedNameText} from '../util/FeedInfoText' import {useSession} from '#/state/session' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export function FeedItem({ post, @@ -103,6 +105,7 @@ let FeedItemInner = ({ }): React.ReactNode => { const {openComposer} = useComposerControls() const pal = usePalette('default') + const {_} = useLingui() const {currentAccount} = useSession() const href = useMemo(() => { const urip = new AtUri(post.uri) @@ -131,6 +134,7 @@ let FeedItemInner = ({ displayName: post.author.displayName, avatar: post.author.avatar, }, + embed: post.embed, }, }) }, [post, record, openComposer]) @@ -181,24 +185,28 @@ let FeedItemInner = ({ style={pal.textLight} lineHeight={1.2} numberOfLines={1}> - From{' '} - <FeedNameText - type="sm-bold" - uri={reason.uri} - href={reason.href} - lineHeight={1.2} - numberOfLines={1} - style={pal.textLight} - /> + <Trans context="from-feed"> + From{' '} + <FeedNameText + type="sm-bold" + uri={reason.uri} + href={reason.href} + lineHeight={1.2} + numberOfLines={1} + style={pal.textLight} + /> + </Trans> </Text> </Link> ) : AppBskyFeedDefs.isReasonRepost(reason) ? ( <Link style={styles.includeReason} href={makeProfileLink(reason.by)} - title={`Reposted by ${sanitizeDisplayName( - reason.by.displayName || reason.by.handle, - )}`}> + title={_( + msg`Reposted by ${sanitizeDisplayName( + reason.by.displayName || reason.by.handle, + )}`, + )}> <FontAwesomeIcon icon="retweet" style={{ @@ -212,17 +220,19 @@ let FeedItemInner = ({ style={pal.textLight} lineHeight={1.2} numberOfLines={1}> - Reposted by{' '} - <TextLinkOnWebOnly - type="sm-bold" - style={pal.textLight} - lineHeight={1.2} - numberOfLines={1} - text={sanitizeDisplayName( - reason.by.displayName || sanitizeHandle(reason.by.handle), - )} - href={makeProfileLink(reason.by)} - /> + <Trans> + Reposted by{' '} + <TextLinkOnWebOnly + type="sm-bold" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1} + text={sanitizeDisplayName( + reason.by.displayName || sanitizeHandle(reason.by.handle), + )} + href={makeProfileLink(reason.by)} + /> + </Trans> </Text> </Link> ) : null} @@ -273,13 +283,15 @@ let FeedItemInner = ({ style={[pal.textLight, s.mr2]} lineHeight={1.2} numberOfLines={1}> - Reply to{' '} - <UserInfoText - type="md" - did={replyAuthorDid} - attr="displayName" - style={[pal.textLight, s.ml2]} - /> + <Trans context="description"> + Reply to{' '} + <UserInfoText + type="md" + did={replyAuthorDid} + attr="displayName" + style={[pal.textLight]} + /> + </Trans> </Text> </View> )} @@ -292,6 +304,7 @@ let FeedItemInner = ({ <PostCtrls post={post} record={record} + richText={richText} onPressReply={onPressReply} showAppealLabelItem={ post.author.did === currentAccount?.did && isModeratedPost @@ -316,6 +329,7 @@ let PostContent = ({ postAuthor: AppBskyFeedDefs.PostView['author'] }): React.ReactNode => { const pal = usePalette('default') + const {_} = useLingui() const [limitLines, setLimitLines] = useState( () => countLines(richText.text) >= MAX_POST_LINES, ) @@ -345,7 +359,7 @@ let PostContent = ({ ) : undefined} {limitLines ? ( <TextLink - text="Show More" + text={_(msg`Show More`)} style={pal.link} onPress={onPressShowMore} href="#" diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index c1a8c0e18..84edee4a1 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -8,6 +8,7 @@ import Svg, {Circle, Line} from 'react-native-svg' import {FeedItem} from './FeedItem' import {usePalette} from 'lib/hooks/usePalette' import {makeProfileLink} from 'lib/routes/links' +import {Trans} from '@lingui/macro' let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => { if (slice.isThread && slice.items.length > 3) { @@ -99,7 +100,7 @@ function ViewFullThread({slice}: {slice: FeedPostSlice}) { </View> <Text type="md" style={[pal.link, {paddingTop: 18, paddingBottom: 4}]}> - View full thread + <Trans>View full thread</Trans> </Text> </Link> ) diff --git a/src/view/com/posts/FollowingEmptyState.tsx b/src/view/com/posts/FollowingEmptyState.tsx index aac29603d..ef02039af 100644 --- a/src/view/com/posts/FollowingEmptyState.tsx +++ b/src/view/com/posts/FollowingEmptyState.tsx @@ -12,6 +12,7 @@ import {NavigationProp} from 'lib/routes/types' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {isWeb} from 'platform/detection' +import {Trans} from '@lingui/macro' export function FollowingEmptyState() { const pal = usePalette('default') @@ -43,15 +44,17 @@ export function FollowingEmptyState() { <MagnifyingGlassIcon style={[styles.icon, pal.text]} size={62} /> </View> <Text type="xl-medium" style={[s.textCenter, pal.text]}> - Your following feed is empty! Follow more users to see what's - happening. + <Trans> + Your following feed is empty! Follow more users to see what's + happening. + </Trans> </Text> <Button type="inverted" style={styles.emptyBtn} onPress={onPressFindAccounts}> <Text type="lg-medium" style={palInverted.text}> - Find accounts to follow + <Trans>Find accounts to follow</Trans> </Text> <FontAwesomeIcon icon="angle-right" @@ -61,14 +64,14 @@ export function FollowingEmptyState() { </Button> <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}> - You can also discover new Custom Feeds to follow. + <Trans>You can also discover new Custom Feeds to follow.</Trans> </Text> <Button type="inverted" style={[styles.emptyBtn, s.mt10]} onPress={onPressDiscoverFeeds}> <Text type="lg-medium" style={palInverted.text}> - Discover new custom feeds + <Trans>Discover new custom feeds</Trans> </Text> <FontAwesomeIcon icon="angle-right" diff --git a/src/view/com/posts/FollowingEndOfFeed.tsx b/src/view/com/posts/FollowingEndOfFeed.tsx index 3f1297547..bea5bedea 100644 --- a/src/view/com/posts/FollowingEndOfFeed.tsx +++ b/src/view/com/posts/FollowingEndOfFeed.tsx @@ -11,6 +11,7 @@ import {NavigationProp} from 'lib/routes/types' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {isWeb} from 'platform/detection' +import {Trans} from '@lingui/macro' export function FollowingEndOfFeed() { const pal = usePalette('default') @@ -44,15 +45,17 @@ export function FollowingEndOfFeed() { ]}> <View style={styles.inner}> <Text type="xl-medium" style={[s.textCenter, pal.text]}> - You've reached the end of your feed! Find some more accounts to - follow. + <Trans> + You've reached the end of your feed! Find some more accounts to + follow. + </Trans> </Text> <Button type="inverted" style={styles.emptyBtn} onPress={onPressFindAccounts}> <Text type="lg-medium" style={palInverted.text}> - Find accounts to follow + <Trans>Find accounts to follow</Trans> </Text> <FontAwesomeIcon icon="angle-right" @@ -62,14 +65,14 @@ export function FollowingEndOfFeed() { </Button> <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}> - You can also discover new Custom Feeds to follow. + <Trans>You can also discover new Custom Feeds to follow.</Trans> </Text> <Button type="inverted" style={[styles.emptyBtn, s.mt10]} onPress={onPressDiscoverFeeds}> <Text type="lg-medium" style={palInverted.text}> - Discover new custom feeds + <Trans>Discover new custom feeds</Trans> </Text> <FontAwesomeIcon icon="angle-right" diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index 1252f8ca8..9cc635b66 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -5,6 +5,8 @@ import {Button, ButtonType} from '../util/forms/Button' import * as Toast from '../util/Toast' import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {Shadow} from '#/state/cache/types' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' export function FollowButton({ unfollowedType = 'inverted', @@ -18,13 +20,14 @@ export function FollowButton({ labelStyle?: StyleProp<TextStyle> }) { const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) + const {_} = useLingui() const onPressFollow = async () => { try { await queueFollow() } catch (e: any) { if (e?.name !== 'AbortError') { - Toast.show(`An issue occurred, please try again.`) + Toast.show(_(msg`An issue occurred, please try again.`)) } } } @@ -34,7 +37,7 @@ export function FollowButton({ await queueUnfollow() } catch (e: any) { if (e?.name !== 'AbortError') { - Toast.show(`An issue occurred, please try again.`) + Toast.show(_(msg`An issue occurred, please try again.`)) } } } @@ -49,7 +52,7 @@ export function FollowButton({ type={followedType} labelStyle={labelStyle} onPress={onPressUnfollow} - label="Unfollow" + label={_(msg({message: 'Unfollow', context: 'action'}))} /> ) } else { @@ -58,7 +61,7 @@ export function FollowButton({ type={unfollowedType} labelStyle={labelStyle} onPress={onPressFollow} - label="Follow" + label={_(msg({message: 'Follow', context: 'action'}))} /> ) } diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index ef95f5924..266adc51d 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -23,6 +23,7 @@ import {Shadow} from '#/state/cache/types' import {useModerationOpts} from '#/state/queries/preferences' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession} from '#/state/session' +import {Trans} from '@lingui/macro' export function ProfileCard({ testID, @@ -137,7 +138,7 @@ function ProfileCardPills({ {followedBy && ( <View style={[s.mt5, pal.btn, styles.pill]}> <Text type="xs" style={pal.text}> - Follows You + <Trans>Follows You</Trans> </Text> </View> )} @@ -190,8 +191,10 @@ function FollowersList({ style={[styles.followsByDesc, pal.textLight]} numberOfLines={2} lineHeight={1.2}> - Followed by{' '} - {followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')} + <Trans> + Followed by{' '} + {followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')} + </Trans> </Text> {followersWithMods.slice(0, 3).map(({f, mod}) => ( <View key={f.did} style={styles.followedByAviContainer}> diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 7d52216b0..d831ad777 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -192,14 +192,16 @@ let ProfileHeaderLoaded = ({ track('ProfileHeader:FollowButtonClicked') await queueFollow() Toast.show( - `Following ${sanitizeDisplayName( - profile.displayName || profile.handle, - )}`, + _( + msg`Following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, + ), ) } catch (e: any) { if (e?.name !== 'AbortError') { logger.error('Failed to follow', {error: String(e)}) - Toast.show(`There was an issue! ${e.toString()}`) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) } } }) @@ -211,14 +213,16 @@ let ProfileHeaderLoaded = ({ track('ProfileHeader:UnfollowButtonClicked') await queueUnfollow() Toast.show( - `No longer following ${sanitizeDisplayName( - profile.displayName || profile.handle, - )}`, + _( + msg`No longer following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, + ), ) } catch (e: any) { if (e?.name !== 'AbortError') { logger.error('Failed to unfollow', {error: String(e)}) - Toast.show(`There was an issue! ${e.toString()}`) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) } } }) @@ -253,27 +257,27 @@ let ProfileHeaderLoaded = ({ track('ProfileHeader:MuteAccountButtonClicked') try { await queueMute() - Toast.show('Account muted') + Toast.show(_(msg`Account muted`)) } catch (e: any) { if (e?.name !== 'AbortError') { logger.error('Failed to mute account', {error: e}) - Toast.show(`There was an issue! ${e.toString()}`) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) } } - }, [track, queueMute]) + }, [track, queueMute, _]) const onPressUnmuteAccount = React.useCallback(async () => { track('ProfileHeader:UnmuteAccountButtonClicked') try { await queueUnmute() - Toast.show('Account unmuted') + Toast.show(_(msg`Account unmuted`)) } catch (e: any) { if (e?.name !== 'AbortError') { logger.error('Failed to unmute account', {error: e}) - Toast.show(`There was an issue! ${e.toString()}`) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) } } - }, [track, queueUnmute]) + }, [track, queueUnmute, _]) const onPressBlockAccount = React.useCallback(async () => { track('ProfileHeader:BlockAccountButtonClicked') @@ -286,11 +290,11 @@ let ProfileHeaderLoaded = ({ onPressConfirm: async () => { try { await queueBlock() - Toast.show('Account blocked') + Toast.show(_(msg`Account blocked`)) } catch (e: any) { if (e?.name !== 'AbortError') { logger.error('Failed to block account', {error: e}) - Toast.show(`There was an issue! ${e.toString()}`) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) } } }, @@ -308,11 +312,11 @@ let ProfileHeaderLoaded = ({ onPressConfirm: async () => { try { await queueUnblock() - Toast.show('Account unblocked') + Toast.show(_(msg`Account unblocked`)) } catch (e: any) { if (e?.name !== 'AbortError') { logger.error('Failed to unblock account', {error: e}) - Toast.show(`There was an issue! ${e.toString()}`) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) } } }, @@ -451,7 +455,9 @@ let ProfileHeaderLoaded = ({ style={[styles.btn, styles.mainBtn, pal.btn]} accessibilityRole="button" accessibilityLabel={_(msg`Edit profile`)} - accessibilityHint="Opens editor for profile display name, avatar, background image, and description"> + accessibilityHint={_( + msg`Opens editor for profile display name, avatar, background image, and description`, + )}> <Text type="button" style={pal.text}> <Trans>Edit Profile</Trans> </Text> @@ -466,7 +472,7 @@ let ProfileHeaderLoaded = ({ accessibilityLabel={_(msg`Unblock`)} accessibilityHint=""> <Text type="button" style={[pal.text, s.bold]}> - <Trans>Unblock</Trans> + <Trans context="action">Unblock</Trans> </Text> </TouchableOpacity> ) @@ -488,8 +494,12 @@ let ProfileHeaderLoaded = ({ }, ]} accessibilityRole="button" - accessibilityLabel={`Show follows similar to ${profile.handle}`} - accessibilityHint={`Shows a list of users similar to this user.`}> + accessibilityLabel={_( + msg`Show follows similar to ${profile.handle}`, + )} + accessibilityHint={_( + msg`Shows a list of users similar to this user.`, + )}> <FontAwesomeIcon icon="user-plus" style={[ @@ -511,8 +521,10 @@ let ProfileHeaderLoaded = ({ onPress={onPressUnfollow} style={[styles.btn, styles.mainBtn, pal.btn]} accessibilityRole="button" - accessibilityLabel={`Unfollow ${profile.handle}`} - accessibilityHint={`Hides posts from ${profile.handle} in your feed`}> + accessibilityLabel={_(msg`Unfollow ${profile.handle}`)} + accessibilityHint={_( + msg`Hides posts from ${profile.handle} in your feed`, + )}> <FontAwesomeIcon icon="check" style={[pal.text, s.mr5]} @@ -528,8 +540,10 @@ let ProfileHeaderLoaded = ({ onPress={onPressFollow} style={[styles.btn, styles.mainBtn, palInverted.view]} accessibilityRole="button" - accessibilityLabel={`Follow ${profile.handle}`} - accessibilityHint={`Shows posts from ${profile.handle} in your feed`}> + accessibilityLabel={_(msg`Follow ${profile.handle}`)} + accessibilityHint={_( + msg`Shows posts from ${profile.handle} in your feed`, + )}> <FontAwesomeIcon icon="plus" style={[palInverted.text, s.mr5]} @@ -580,7 +594,7 @@ let ProfileHeaderLoaded = ({ invalidHandle ? styles.invalidHandle : undefined, styles.handle, ]}> - {invalidHandle ? 'âš Invalid Handle' : `@${profile.handle}`} + {invalidHandle ? _(msg`âš Invalid Handle`) : `@${profile.handle}`} </ThemedText> </View> {!blockHide && ( @@ -597,7 +611,7 @@ let ProfileHeaderLoaded = ({ } asAnchor accessibilityLabel={`${followers} ${pluralizedFollowers}`} - accessibilityHint={'Opens followers list'}> + accessibilityHint={_(msg`Opens followers list`)}> <Text type="md" style={[s.bold, pal.text]}> {followers}{' '} </Text> @@ -615,14 +629,16 @@ let ProfileHeaderLoaded = ({ }) } asAnchor - accessibilityLabel={`${following} following`} - accessibilityHint={'Opens following list'}> - <Text type="md" style={[s.bold, pal.text]}> - {following}{' '} - </Text> - <Text type="md" style={[pal.textLight]}> - <Trans>following</Trans> - </Text> + accessibilityLabel={_(msg`${following} following`)} + accessibilityHint={_(msg`Opens following list`)}> + <Trans> + <Text type="md" style={[s.bold, pal.text]}> + {following}{' '} + </Text> + <Text type="md" style={[pal.textLight]}> + following + </Text> + </Trans> </Link> <Text type="md" style={[s.bold, pal.text]}> {formatCount(profile.postsCount || 0)}{' '} @@ -682,7 +698,7 @@ let ProfileHeaderLoaded = ({ testID="profileHeaderAviButton" onPress={onPressAvi} accessibilityRole="image" - accessibilityLabel={`View ${profile.handle}'s avatar`} + accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)} accessibilityHint=""> <View style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx index ce5cf92c5..6edc61fcf 100644 --- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx +++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx @@ -21,6 +21,7 @@ import {useModerationOpts} from '#/state/queries/preferences' import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileFollowMutationQueue} from '#/state/queries/profile' +import {Trans} from '@lingui/macro' const OUTER_PADDING = 10 const INNER_PADDING = 14 @@ -60,7 +61,7 @@ export function ProfileHeaderSuggestedFollows({ paddingRight: INNER_PADDING / 2, }}> <Text type="sm-bold" style={[pal.textLight]}> - Suggested for you + <Trans>Suggested for you</Trans> </Text> <Pressable diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx index 0e245f0f4..eaf00f3e6 100644 --- a/src/view/com/profile/ProfileSubpageHeader.tsx +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -16,7 +16,7 @@ import {BACK_HITSLOP} from 'lib/constants' import {isNative} from 'platform/detection' import {useLightboxControls, ImagesLightbox} from '#/state/lightbox' import {useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' +import {Trans, msg} from '@lingui/macro' import {useSetDrawerOpen} from '#/state/shell' import {emitSoftReset} from '#/state/events' @@ -153,17 +153,19 @@ export function ProfileSubpageHeader({ <LoadingPlaceholder width={50} height={8} /> ) : ( <Text type="xl" style={[pal.textLight]} numberOfLines={1}> - by{' '} {!creator ? ( - '—' + <Trans>by —</Trans> ) : isOwner ? ( - 'you' + <Trans>by you</Trans> ) : ( - <TextLink - text={sanitizeHandle(creator.handle, '@')} - href={makeProfileLink(creator)} - style={pal.textLight} - /> + <Trans> + by{' '} + <TextLink + text={sanitizeHandle(creator.handle, '@')} + href={makeProfileLink(creator)} + style={pal.textLight} + /> + </Trans> )} </Text> )} diff --git a/src/view/com/util/AccountDropdownBtn.tsx b/src/view/com/util/AccountDropdownBtn.tsx index 76d493886..221879df7 100644 --- a/src/view/com/util/AccountDropdownBtn.tsx +++ b/src/view/com/util/AccountDropdownBtn.tsx @@ -22,7 +22,7 @@ export function AccountDropdownBtn({account}: {account: SessionAccount}) { label: _(msg`Remove account`), onPress: () => { removeAccount(account) - Toast.show('Account removed from quick access') + Toast.show(_(msg`Account removed from quick access`)) }, icon: { ios: { diff --git a/src/view/com/util/BlurView.android.tsx b/src/view/com/util/BlurView.android.tsx new file mode 100644 index 000000000..eee1d9d86 --- /dev/null +++ b/src/view/com/util/BlurView.android.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import {StyleSheet, View, ViewProps} from 'react-native' +import {addStyle} from 'lib/styles' + +type BlurViewProps = ViewProps & { + blurType?: 'dark' | 'light' + blurAmount?: number +} + +export const BlurView = ({ + style, + blurType, + ...props +}: React.PropsWithChildren<BlurViewProps>) => { + if (blurType === 'dark') { + style = addStyle(style, styles.dark) + } else { + style = addStyle(style, styles.light) + } + return <View style={style} {...props} /> +} + +const styles = StyleSheet.create({ + dark: { + backgroundColor: '#0008', + }, + light: { + backgroundColor: '#fff8', + }, +}) 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/Selector.tsx b/src/view/com/util/Selector.tsx index 223a069c8..66e363cd4 100644 --- a/src/view/com/util/Selector.tsx +++ b/src/view/com/util/Selector.tsx @@ -2,6 +2,8 @@ import React, {createRef, useState, useMemo, useRef} from 'react' import {Animated, Pressable, StyleSheet, View} from 'react-native' import {Text} from './text/Text' import {usePalette} from 'lib/hooks/usePalette' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' interface Layout { x: number @@ -19,6 +21,7 @@ export function Selector({ panX: Animated.Value onSelect?: (index: number) => void }) { + const {_} = useLingui() const containerRef = useRef<View>(null) const pal = usePalette('default') const [itemLayouts, setItemLayouts] = useState<undefined | Layout[]>( @@ -100,8 +103,8 @@ export function Selector({ testID={`selector-${i}`} key={item} onPress={() => onPressItem(i)} - accessibilityLabel={`Select ${item}`} - accessibilityHint={`Select option ${i} of ${numItems}`}> + accessibilityLabel={_(msg`Select ${item}`)} + accessibilityHint={_(msg`Select option ${i} of ${numItems}`)}> <View style={styles.item} ref={itemRefs[i]}> <Text style={ 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/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 082cae59c..1ccfcf56c 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -11,6 +11,8 @@ import {NavigationProp} from 'lib/routes/types' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import Animated from 'react-native-reanimated' import {useSetDrawerOpen} from '#/state/shell' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} @@ -32,6 +34,7 @@ export function ViewHeader({ renderButton?: () => JSX.Element }) { const pal = usePalette('default') + const {_} = useLingui() const setDrawerOpen = useSetDrawerOpen() const navigation = useNavigation<NavigationProp>() const {track} = useAnalytics() @@ -75,9 +78,9 @@ export function ViewHeader({ hitSlop={BACK_HITSLOP} style={canGoBack ? styles.backBtn : styles.backBtnWide} accessibilityRole="button" - accessibilityLabel={canGoBack ? 'Back' : 'Menu'} + accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)} accessibilityHint={ - canGoBack ? '' : 'Access navigation links and settings' + canGoBack ? '' : _(msg`Access navigation links and settings`) }> {canGoBack ? ( <FontAwesomeIcon diff --git a/src/view/com/util/error/ErrorMessage.tsx b/src/view/com/util/error/ErrorMessage.tsx index b4adbb557..a4238b8a4 100644 --- a/src/view/com/util/error/ErrorMessage.tsx +++ b/src/view/com/util/error/ErrorMessage.tsx @@ -53,7 +53,9 @@ export function ErrorMessage({ onPress={onPressTryAgain} accessibilityRole="button" accessibilityLabel={_(msg`Retry`)} - accessibilityHint="Retries the last action, which errored out"> + accessibilityHint={_( + msg`Retries the last action, which errored out`, + )}> <FontAwesomeIcon icon="arrows-rotate" style={{color: theme.palette.error.icon}} diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx index 4cd6dd4b4..45444331c 100644 --- a/src/view/com/util/error/ErrorScreen.tsx +++ b/src/view/com/util/error/ErrorScreen.tsx @@ -63,14 +63,16 @@ export function ErrorScreen({ style={[styles.btn]} onPress={onPressTryAgain} accessibilityLabel={_(msg`Retry`)} - accessibilityHint="Retries the last action, which errored out"> + accessibilityHint={_( + msg`Retries the last action, which errored out`, + )}> <FontAwesomeIcon icon="arrows-rotate" style={pal.link as FontAwesomeIconStyle} size={16} /> <Text type="button" style={[styles.btnText, pal.link]}> - <Trans>Try again</Trans> + <Trans context="action">Try again</Trans> </Text> </Button> </View> 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/DateInput.tsx b/src/view/com/util/forms/DateInput.tsx index 4aa5cb610..c5f0afc8f 100644 --- a/src/view/com/util/forms/DateInput.tsx +++ b/src/view/com/util/forms/DateInput.tsx @@ -13,6 +13,9 @@ import {Text} from '../text/Text' import {TypographyVariant} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' +import {getLocales} from 'expo-localization' + +const LOCALE = getLocales()[0] interface Props { testID?: string @@ -25,6 +28,7 @@ interface Props { accessibilityLabel: string accessibilityHint: string accessibilityLabelledBy?: string + handleAsUTC?: boolean } export function DateInput(props: Props) { @@ -32,6 +36,12 @@ export function DateInput(props: Props) { const theme = useTheme() const pal = usePalette('default') + const formatter = React.useMemo(() => { + return new Intl.DateTimeFormat(LOCALE.languageTag, { + timeZone: props.handleAsUTC ? 'UTC' : undefined, + }) + }, [props.handleAsUTC]) + const onChangeInternal = useCallback( (event: DateTimePickerEvent, date: Date | undefined) => { setShow(false) @@ -64,7 +74,7 @@ export function DateInput(props: Props) { <Text type={props.buttonLabelType} style={[pal.text, props.buttonLabelStyle]}> - {props.value.toLocaleDateString()} + {formatter.format(props.value)} </Text> </View> </Button> @@ -73,6 +83,7 @@ export function DateInput(props: Props) { <DateTimePicker testID={props.testID ? `${props.testID}-datepicker` : undefined} mode="date" + timeZoneName={props.handleAsUTC ? 'Etc/UTC' : undefined} display="spinner" // @ts-ignore applies in iOS only -prf themeVariant={theme.colorScheme} diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index ad8f50f5e..411b77484 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -75,6 +75,8 @@ export function DropdownButton({ bottomOffset = 0, accessibilityLabel, }: PropsWithChildren<DropdownButtonProps>) { + const {_} = useLingui() + const ref1 = useRef<TouchableOpacity>(null) const ref2 = useRef<View>(null) @@ -141,7 +143,9 @@ export function DropdownButton({ hitSlop={HITSLOP_10} ref={ref1} accessibilityRole="button" - accessibilityLabel={accessibilityLabel || `Opens ${numItems} options`} + accessibilityLabel={ + accessibilityLabel || _(msg`Opens ${numItems} options`) + } accessibilityHint=""> {children} </TouchableOpacity> @@ -247,7 +251,7 @@ const DropdownItems = ({ onPress={() => onPressItem(index)} accessibilityRole="button" accessibilityLabel={item.label} - accessibilityHint={`Option ${index + 1} of ${numItems}`}> + accessibilityHint={_(msg`Option ${index + 1} of ${numItems}`)}> {item.icon && ( <FontAwesomeIcon style={styles.icon} diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx new file mode 100644 index 000000000..9e9888ad8 --- /dev/null +++ b/src/view/com/util/forms/NativeDropdown.web.tsx @@ -0,0 +1,241 @@ +import React from 'react' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import {Pressable, StyleSheet, View, Text} from 'react-native' +import {IconProp} from '@fortawesome/fontawesome-svg-core' +import {MenuItemCommonProps} from 'zeego/lib/typescript/menu' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {HITSLOP_10} from 'lib/constants' + +// Custom Dropdown Menu Components +// == +export const DropdownMenuRoot = DropdownMenu.Root +export const DropdownMenuContent = DropdownMenu.Content + +type ItemProps = React.ComponentProps<(typeof DropdownMenu)['Item']> +export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => { + const theme = useTheme() + const [focused, setFocused] = React.useState(false) + const backgroundColor = theme.colorScheme === 'dark' ? '#fff1' : '#0001' + + return ( + <DropdownMenu.Item + {...props} + style={StyleSheet.flatten([ + styles.item, + focused && {backgroundColor: backgroundColor}, + ])} + onFocus={() => { + setFocused(true) + }} + onBlur={() => { + setFocused(false) + }} + /> + ) +} + +// Types for Dropdown Menu and Items +export type DropdownItem = { + label: string | 'separator' + onPress?: () => void + testID?: string + icon?: { + ios: MenuItemCommonProps['ios'] + android: string + web: IconProp + } +} +type Props = { + items: DropdownItem[] + testID?: string + accessibilityLabel?: string + accessibilityHint?: string +} + +export function NativeDropdown({ + items, + children, + testID, + accessibilityLabel, + accessibilityHint, +}: React.PropsWithChildren<Props>) { + const pal = usePalette('default') + const theme = useTheme() + const dropDownBackgroundColor = + theme.colorScheme === 'dark' ? pal.btn : pal.view + const [open, setOpen] = React.useState(false) + const buttonRef = React.useRef<HTMLButtonElement>(null) + const menuRef = React.useRef<HTMLDivElement>(null) + const {borderColor: separatorColor} = + theme.colorScheme === 'dark' ? pal.borderDark : pal.border + + React.useEffect(() => { + function clickHandler(e: MouseEvent) { + const t = e.target + + if (!open) return + if (!t) return + if (!buttonRef.current || !menuRef.current) return + + if ( + t !== buttonRef.current && + !buttonRef.current.contains(t as Node) && + t !== menuRef.current && + !menuRef.current.contains(t as Node) + ) { + // prevent clicking through to links beneath dropdown + // only applies to mobile web + e.preventDefault() + e.stopPropagation() + + // close menu + setOpen(false) + } + } + + function keydownHandler(e: KeyboardEvent) { + if (e.key === 'Escape' && open) { + setOpen(false) + } + } + + document.addEventListener('click', clickHandler, true) + window.addEventListener('keydown', keydownHandler, true) + return () => { + document.removeEventListener('click', clickHandler, true) + window.removeEventListener('keydown', keydownHandler, true) + } + }, [open, setOpen]) + + return ( + <DropdownMenuRoot open={open} onOpenChange={o => setOpen(o)}> + <DropdownMenu.Trigger asChild onPointerDown={e => e.preventDefault()}> + <Pressable + ref={buttonRef as unknown as React.Ref<View>} + testID={testID} + accessibilityRole="button" + accessibilityLabel={accessibilityLabel} + accessibilityHint={accessibilityHint} + onPress={() => setOpen(o => !o)} + hitSlop={HITSLOP_10}> + {children} + </Pressable> + </DropdownMenu.Trigger> + + <DropdownMenu.Portal> + <DropdownMenu.Content + ref={menuRef} + style={ + StyleSheet.flatten([ + styles.content, + dropDownBackgroundColor, + ]) as React.CSSProperties + } + loop> + {items.map((item, index) => { + if (item.label === 'separator') { + return ( + <DropdownMenu.Separator + key={getKey(item.label, index, item.testID)} + style={ + StyleSheet.flatten([ + styles.separator, + {backgroundColor: separatorColor}, + ]) as React.CSSProperties + } + /> + ) + } + if (index > 1 && items[index - 1].label === 'separator') { + return ( + <DropdownMenu.Group + key={getKey(item.label, index, item.testID)}> + <DropdownMenuItem + key={getKey(item.label, index, item.testID)} + onSelect={item.onPress}> + <Text + selectable={false} + style={[pal.text, styles.itemTitle]}> + {item.label} + </Text> + {item.icon && ( + <FontAwesomeIcon + icon={item.icon.web} + size={20} + color={pal.colors.textLight} + /> + )} + </DropdownMenuItem> + </DropdownMenu.Group> + ) + } + return ( + <DropdownMenuItem + key={getKey(item.label, index, item.testID)} + onSelect={item.onPress}> + <Text selectable={false} style={[pal.text, styles.itemTitle]}> + {item.label} + </Text> + {item.icon && ( + <FontAwesomeIcon + icon={item.icon.web} + size={20} + color={pal.colors.textLight} + /> + )} + </DropdownMenuItem> + ) + })} + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenuRoot> + ) +} + +const getKey = (label: string, index: number, id?: string) => { + if (id) { + return id + } + return `${label}_${index}` +} + +const styles = StyleSheet.create({ + separator: { + height: 1, + marginTop: 4, + marginBottom: 4, + }, + content: { + backgroundColor: '#f0f0f0', + borderRadius: 8, + paddingTop: 4, + paddingBottom: 4, + paddingLeft: 4, + paddingRight: 4, + marginTop: 6, + + // @ts-ignore web only -prf + boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px', + }, + item: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + columnGap: 20, + // @ts-ignore -web + cursor: 'pointer', + paddingTop: 8, + paddingBottom: 8, + paddingLeft: 12, + paddingRight: 12, + borderRadius: 8, + }, + itemTitle: { + fontSize: 16, + fontWeight: '500', + paddingRight: 10, + }, +}) diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 1f2e067c2..b21caf2e7 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -2,7 +2,12 @@ import React, {memo} from 'react' import {Linking, StyleProp, View, ViewStyle} from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {AppBskyActorDefs, AppBskyFeedPost, AtUri} from '@atproto/api' +import { + AppBskyActorDefs, + AppBskyFeedPost, + AtUri, + RichText as RichTextAPI, +} from '@atproto/api' import {toShareUrl} from 'lib/strings/url-helpers' import {useTheme} from 'lib/ThemeContext' import {shareUrl} from 'lib/sharing' @@ -24,6 +29,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useSession} from '#/state/session' import {isWeb} from '#/platform/detection' +import {richTextToString} from '#/lib/strings/rich-text-helpers' let PostDropdownBtn = ({ testID, @@ -31,6 +37,7 @@ let PostDropdownBtn = ({ postCid, postUri, record, + richText, style, showAppealLabelItem, }: { @@ -39,6 +46,7 @@ let PostDropdownBtn = ({ postCid: string postUri: string record: AppBskyFeedPost.Record + richText: RichTextAPI style?: StyleProp<ViewStyle> showAppealLabelItem?: boolean }): React.ReactNode => { @@ -71,32 +79,36 @@ let PostDropdownBtn = ({ const onDeletePost = React.useCallback(() => { postDeleteMutation.mutateAsync({uri: postUri}).then( () => { - Toast.show('Post deleted') + Toast.show(_(msg`Post deleted`)) }, e => { logger.error('Failed to delete post', {error: e}) - Toast.show('Failed to delete post, please try again') + Toast.show(_(msg`Failed to delete post, please try again`)) }, ) - }, [postUri, postDeleteMutation]) + }, [postUri, postDeleteMutation, _]) const onToggleThreadMute = React.useCallback(() => { try { const muted = toggleThreadMute(rootUri) if (muted) { - Toast.show('You will no longer receive notifications for this thread') + Toast.show( + _(msg`You will no longer receive notifications for this thread`), + ) } else { - Toast.show('You will now receive notifications for this thread') + Toast.show(_(msg`You will now receive notifications for this thread`)) } } catch (e) { logger.error('Failed to toggle thread mute', {error: e}) } - }, [rootUri, toggleThreadMute]) + }, [rootUri, toggleThreadMute, _]) const onCopyPostText = React.useCallback(() => { - Clipboard.setString(record?.text || '') - Toast.show('Copied to clipboard') - }, [record]) + const str = richTextToString(richText, true) + + Clipboard.setString(str) + Toast.show(_(msg`Copied to clipboard`)) + }, [_, richText]) const onOpenTranslate = React.useCallback(() => { Linking.openURL(translatorUrl) @@ -253,7 +265,7 @@ let PostDropdownBtn = ({ <NativeDropdown testID={testID} items={dropdownItems} - accessibilityLabel="More post options" + accessibilityLabel={_(msg`More post options`)} accessibilityHint=""> <View style={style}> <FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} /> diff --git a/src/view/com/util/forms/SearchInput.tsx b/src/view/com/util/forms/SearchInput.tsx index 02b462b55..a78d23c9b 100644 --- a/src/view/com/util/forms/SearchInput.tsx +++ b/src/view/com/util/forms/SearchInput.tsx @@ -11,6 +11,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {HITSLOP_10} from 'lib/constants' import {MagnifyingGlassIcon} from 'lib/icons' import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' @@ -49,7 +50,7 @@ export function SearchInput({ <TextInput testID="searchTextInput" ref={textInput} - placeholder="Search" + placeholder={_(msg`Search`)} placeholderTextColor={pal.colors.textLight} selectTextOnFocus returnKeyType="search" @@ -71,7 +72,8 @@ export function SearchInput({ onPress={onPressCancelSearchInner} accessibilityRole="button" accessibilityLabel={_(msg`Clear search query`)} - accessibilityHint=""> + accessibilityHint="" + hitSlop={HITSLOP_10}> <FontAwesomeIcon icon="xmark" size={16} diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 6f203bf06..61cb6f69f 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -4,6 +4,8 @@ import {Image} from 'expo-image' import {clamp} from 'lib/numbers' import {Dimensions} from 'lib/media/types' import * as imageSizes from 'lib/media/image-sizes' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' const MIN_ASPECT_RATIO = 0.33 // 1/3 const MAX_ASPECT_RATIO = 10 // 10/1 @@ -29,6 +31,7 @@ export function AutoSizedImage({ style, children = null, }: Props) { + const {_} = useLingui() const [dim, setDim] = React.useState<Dimensions | undefined>( dimensionsHint || imageSizes.get(uri), ) @@ -64,7 +67,7 @@ export function AutoSizedImage({ accessible={true} // Must set for `accessibilityLabel` to work accessibilityIgnoresInvertColors accessibilityLabel={alt} - accessibilityHint="Tap to view fully" + accessibilityHint={_(msg`Tap to view fully`)} /> {children} </Pressable> diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 094b0c56c..e7110372c 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -2,6 +2,8 @@ import {AppBskyEmbedImages} from '@atproto/api' import React, {ComponentProps, FC} from 'react' import {StyleSheet, Text, Pressable, View} from 'react-native' import {Image} from 'expo-image' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type EventFunction = (index: number) => void @@ -22,6 +24,7 @@ export const GalleryItem: FC<GalleryItemProps> = ({ onPressIn, onLongPress, }) => { + const {_} = useLingui() const image = images[index] return ( <View style={styles.fullWidth}> @@ -31,7 +34,7 @@ export const GalleryItem: FC<GalleryItemProps> = ({ onLongPress={onLongPress ? () => onLongPress(index) : undefined} style={styles.fullWidth} accessibilityRole="button" - accessibilityLabel={image.alt || 'Image'} + accessibilityLabel={image.alt || _(msg`Image`)} accessibilityHint=""> <Image source={{uri: image.thumb}} diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx index 1269b7ebf..b3a563116 100644 --- a/src/view/com/util/moderation/ContentHider.tsx +++ b/src/view/com/util/moderation/ContentHider.tsx @@ -63,7 +63,9 @@ export function ContentHider({ } }} accessibilityRole="button" - accessibilityHint={override ? 'Hide the content' : 'Show the content'} + accessibilityHint={ + override ? _(msg`Hide the content`) : _(msg`Show the content`) + } accessibilityLabel="" style={[ styles.cover, @@ -92,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}> @@ -129,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/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx index bffb7ea1a..b1fa71d4a 100644 --- a/src/view/com/util/moderation/PostHider.tsx +++ b/src/view/com/util/moderation/PostHider.tsx @@ -9,7 +9,7 @@ import {addStyle} from 'lib/styles' import {describeModerationCause} from 'lib/moderation' import {ShieldExclamation} from 'lib/icons' import {useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' +import {Trans, msg} from '@lingui/macro' import {useModalControls} from '#/state/modals' interface Props extends ComponentProps<typeof Link> { @@ -57,7 +57,9 @@ export function PostHider({ } }} accessibilityRole="button" - accessibilityHint={override ? 'Hide the content' : 'Show the content'} + accessibilityHint={ + override ? _(msg`Hide the content`) : _(msg`Show the content`) + } accessibilityLabel="" style={[ styles.description, @@ -103,7 +105,7 @@ export function PostHider({ </Text> {!moderation.noOverride && ( <Text type="sm" style={[styles.showBtn, pal.link]}> - {override ? 'Hide' : 'Show'} + {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>} </Text> )} </Pressable> diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index a50b52175..50ef8a875 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -6,7 +6,11 @@ import { View, ViewStyle, } from 'react-native' -import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + RichText as RichTextAPI, +} from '@atproto/api' import {Text} from '../text/Text' import {PostDropdownBtn} from '../forms/PostDropdownBtn' import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons' @@ -26,11 +30,14 @@ import { import {useComposerControls} from '#/state/shell/composer' import {Shadow} from '#/state/cache/types' import {useRequireAuth} from '#/state/session' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' let PostCtrls = ({ big, post, record, + richText, showAppealLabelItem, style, onPressReply, @@ -38,11 +45,13 @@ let PostCtrls = ({ big?: boolean post: Shadow<AppBskyFeedDefs.PostView> record: AppBskyFeedPost.Record + richText: RichTextAPI showAppealLabelItem?: boolean style?: StyleProp<ViewStyle> onPressReply: () => void }): React.ReactNode => { const theme = useTheme() + const {_} = useLingui() const {openComposer} = useComposerControls() const {closeModal} = useModalControls() const postLikeMutation = usePostLikeMutation() @@ -176,9 +185,9 @@ let PostCtrls = ({ requireAuth(() => onPressToggleLike()) }} accessibilityRole="button" - accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${ - post.likeCount - } ${pluralize(post.likeCount || 0, 'like')})`} + accessibilityLabel={`${ + post.viewer?.like ? _(msg`Unlike`) : _(msg`Like`) + } (${post.likeCount} ${pluralize(post.likeCount || 0, 'like')})`} accessibilityHint="" hitSlop={big ? HITSLOP_20 : HITSLOP_10}> {post.viewer?.like ? ( @@ -209,6 +218,7 @@ let PostCtrls = ({ postCid={post.cid} postUri={post.uri} record={record} + richText={richText} showAppealLabelItem={showAppealLabelItem} style={styles.ctrlPad} /> diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index 620852d8e..d45bf1d87 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -8,6 +8,8 @@ import {pluralize} from 'lib/strings/helpers' import {HITSLOP_10, HITSLOP_20} from 'lib/constants' import {useModalControls} from '#/state/modals' import {useRequireAuth} from '#/state/session' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' interface Props { isReposted: boolean @@ -25,6 +27,7 @@ let RepostButton = ({ onQuote, }: Props): React.ReactNode => { const theme = useTheme() + const {_} = useLingui() const {openModal} = useModalControls() const requireAuth = useRequireAuth() @@ -53,7 +56,9 @@ let RepostButton = ({ style={[styles.control, !big && styles.controlPad]} accessibilityRole="button" accessibilityLabel={`${ - isReposted ? 'Undo repost' : 'Repost' + isReposted + ? _(msg`Undo repost`) + : _(msg({message: 'Repost', context: 'action'})) } (${repostCount} ${pluralize(repostCount || 0, 'repost')})`} accessibilityHint="" hitSlop={big ? HITSLOP_20 : HITSLOP_10}> diff --git a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx new file mode 100644 index 000000000..f06c8b794 --- /dev/null +++ b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx @@ -0,0 +1,170 @@ +import {EmbedPlayerParams, getGifDims} from 'lib/strings/embed-player' +import React from 'react' +import {Image, ImageLoadEventData} from 'expo-image' +import { + ActivityIndicator, + GestureResponderEvent, + LayoutChangeEvent, + Pressable, + StyleSheet, + View, +} from 'react-native' +import {isIOS, isNative, isWeb} from '#/platform/detection' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useExternalEmbedsPrefs} from 'state/preferences' +import {useModalControls} from 'state/modals' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {AppBskyEmbedExternal} from '@atproto/api' + +export function ExternalGifEmbed({ + link, + params, +}: { + link: AppBskyEmbedExternal.ViewExternal + params: EmbedPlayerParams +}) { + const externalEmbedsPrefs = useExternalEmbedsPrefs() + const {openModal} = useModalControls() + const {_} = useLingui() + + const thumbHasLoaded = React.useRef(false) + const viewWidth = React.useRef(0) + + // Tracking if the placer has been activated + const [isPlayerActive, setIsPlayerActive] = React.useState(false) + // Tracking whether the gif has been loaded yet + const [isPrefetched, setIsPrefetched] = React.useState(false) + // Tracking whether the image is animating + const [isAnimating, setIsAnimating] = React.useState(true) + const [imageDims, setImageDims] = React.useState({height: 100, width: 1}) + + // Used for controlling animation + const imageRef = React.useRef<Image>(null) + + const load = React.useCallback(() => { + setIsPlayerActive(true) + Image.prefetch(params.playerUri).then(() => { + // Replace the image once it's fetched + setIsPrefetched(true) + }) + }, [params.playerUri]) + + const onPlayPress = React.useCallback( + (event: GestureResponderEvent) => { + // Don't propagate on web + event.preventDefault() + + // Show consent if this is the first load + if (externalEmbedsPrefs?.[params.source] === undefined) { + openModal({ + name: 'embed-consent', + source: params.source, + onAccept: load, + }) + return + } + // If the player isn't active, we want to activate it and prefetch the gif + if (!isPlayerActive) { + load() + return + } + // Control animation on native + setIsAnimating(prev => { + if (prev) { + if (isNative) { + imageRef.current?.stopAnimating() + } + return false + } else { + if (isNative) { + imageRef.current?.startAnimating() + } + return true + } + }) + }, + [externalEmbedsPrefs, isPlayerActive, load, openModal, params.source], + ) + + const onLoad = React.useCallback((e: ImageLoadEventData) => { + if (thumbHasLoaded.current) return + setImageDims(getGifDims(e.source.height, e.source.width, viewWidth.current)) + thumbHasLoaded.current = true + }, []) + + const onLayout = React.useCallback((e: LayoutChangeEvent) => { + viewWidth.current = e.nativeEvent.layout.width + }, []) + + return ( + <Pressable + style={[ + {height: imageDims.height}, + styles.topRadius, + styles.gifContainer, + ]} + onPress={onPlayPress} + onLayout={onLayout} + accessibilityRole="button" + accessibilityHint={_(msg`Plays the GIF`)} + accessibilityLabel={_(msg`Play ${link.title}`)}> + {(!isPrefetched || !isAnimating) && ( // If we have not loaded or are not animating, show the overlay + <View style={[styles.layer, styles.overlayLayer]}> + <View style={[styles.overlayContainer, styles.topRadius]}> + {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active + <FontAwesomeIcon icon="play" size={42} color="white" /> + ) : ( + // Activity indicator while gif loads + <ActivityIndicator size="large" color="white" /> + )} + </View> + </View> + )} + <Image + source={{ + uri: + !isPrefetched || (isWeb && !isAnimating) + ? link.thumb + : params.playerUri, + }} // Web uses the thumb to control playback + style={{flex: 1}} + ref={imageRef} + onLoad={onLoad} + autoplay={isAnimating} + contentFit="contain" + accessibilityIgnoresInvertColors + accessibilityLabel={link.title} + accessibilityHint={link.title} + cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios + /> + </Pressable> + ) +} + +const styles = StyleSheet.create({ + topRadius: { + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + }, + layer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + overlayContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.5)', + }, + overlayLayer: { + zIndex: 2, + }, + gifContainer: { + width: '100%', + overflow: 'hidden', + }, +}) diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index 27aa804d3..aaa98a41f 100644 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -8,6 +8,8 @@ import {AppBskyEmbedExternal} from '@atproto/api' import {toNiceDomain} from 'lib/strings/url-helpers' import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player' import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed' +import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed' +import {useExternalEmbedsPrefs} from 'state/preferences' export const ExternalLinkEmbed = ({ link, @@ -16,69 +18,47 @@ export const ExternalLinkEmbed = ({ }) => { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() + const externalEmbedPrefs = useExternalEmbedsPrefs() - const embedPlayerParams = React.useMemo( - () => parseEmbedPlayerFromUrl(link.uri), - [link.uri], - ) + const embedPlayerParams = React.useMemo(() => { + const params = parseEmbedPlayerFromUrl(link.uri) + + if (params && externalEmbedPrefs?.[params.source] !== 'hide') { + return params + } + }, [link.uri, externalEmbedPrefs]) return ( - <View - style={{ - flexDirection: !isMobile && !embedPlayerParams ? 'row' : 'column', - }}> + <View style={styles.container}> {link.thumb && !embedPlayerParams ? ( - <View - style={ - !isMobile - ? { - borderTopLeftRadius: 6, - borderBottomLeftRadius: 6, - width: 120, - aspectRatio: 1, - overflow: 'hidden', - } - : { - borderTopLeftRadius: 6, - borderTopRightRadius: 6, - width: '100%', - height: 200, - overflow: 'hidden', - } - }> - <Image - style={styles.extImage} - source={{uri: link.thumb}} - accessibilityIgnoresInvertColors - /> - </View> + <Image + style={{aspectRatio: 1.91}} + source={{uri: link.thumb}} + accessibilityIgnoresInvertColors + /> ) : undefined} - {embedPlayerParams && ( - <ExternalPlayer link={link} params={embedPlayerParams} /> - )} - <View - style={{ - paddingHorizontal: isMobile ? 10 : 14, - paddingTop: 8, - paddingBottom: 10, - flex: !isMobile ? 1 : undefined, - }}> + {(embedPlayerParams?.isGif && ( + <ExternalGifEmbed link={link} params={embedPlayerParams} /> + )) || + (embedPlayerParams && ( + <ExternalPlayer link={link} params={embedPlayerParams} /> + ))} + <View style={[styles.info, {paddingHorizontal: isMobile ? 10 : 14}]}> <Text type="sm" numberOfLines={1} style={[pal.textLight, styles.extUri]}> {toNiceDomain(link.uri)} </Text> - <Text - type="lg-bold" - numberOfLines={isMobile ? 4 : 2} - style={[pal.text]}> - {link.title || link.uri} - </Text> - {link.description ? ( + {!embedPlayerParams?.isGif && ( + <Text type="lg-bold" numberOfLines={3} style={[pal.text]}> + {link.title || link.uri} + </Text> + )} + {link.description && !embedPlayerParams?.hideDetails ? ( <Text type="md" - numberOfLines={isMobile ? 4 : 2} + numberOfLines={link.thumb ? 2 : 4} style={[pal.text, styles.extDescription]}> {link.description} </Text> @@ -89,9 +69,16 @@ export const ExternalLinkEmbed = ({ } const styles = StyleSheet.create({ - extImage: { + container: { + flexDirection: 'column', + borderRadius: 6, + overflow: 'hidden', + }, + info: { width: '100%', - height: 200, + bottom: 0, + paddingTop: 8, + paddingBottom: 10, }, extUri: { marginTop: 2, diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx index 580cf363a..8b0858b69 100644 --- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx @@ -1,22 +1,32 @@ import React from 'react' import { ActivityIndicator, - Dimensions, GestureResponderEvent, Pressable, StyleSheet, + useWindowDimensions, View, } from 'react-native' +import Animated, { + measure, + runOnJS, + useAnimatedRef, + useFrameCallback, +} from 'react-native-reanimated' import {Image} from 'expo-image' import {WebView} from 'react-native-webview' -import YoutubePlayer from 'react-native-youtube-iframe' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import {AppBskyEmbedExternal} from '@atproto/api' import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player' import {EventStopper} from '../EventStopper' -import {AppBskyEmbedExternal} from '@atproto/api' import {isNative} from 'platform/detection' -import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' +import {useExternalEmbedsPrefs} from 'state/preferences' +import {useModalControls} from 'state/modals' interface ShouldStartLoadRequest { url: string @@ -32,6 +42,8 @@ function PlaceholderOverlay({ isPlayerActive: boolean onPress: (event: GestureResponderEvent) => void }) { + const {_} = useLingui() + // If the player is active and not loading, we don't want to show the overlay. if (isPlayerActive && !isLoading) return null @@ -39,8 +51,8 @@ function PlaceholderOverlay({ <View style={[styles.layer, styles.overlayLayer]}> <Pressable accessibilityRole="button" - accessibilityLabel="Play Video" - accessibilityHint="" + accessibilityLabel={_(msg`Play Video`)} + accessibilityHint={_(msg`Play Video`)} onPress={onPress} style={[styles.overlayContainer, styles.topRadius]}> {!isPlayerActive ? ( @@ -77,31 +89,21 @@ function Player({ return ( <View style={[styles.layer, styles.playerLayer]}> <EventStopper> - {isNative && params.type === 'youtube_video' ? ( - <YoutubePlayer - videoId={params.videoId} - play - height={height} - onReady={onLoad} - webViewStyle={[styles.webview, styles.topRadius]} + <View style={{height, width: '100%'}}> + <WebView + javaScriptEnabled={true} + onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} + mediaPlaybackRequiresUserAction={false} + allowsInlineMediaPlayback + bounces={false} + allowsFullscreenVideo + nestedScrollEnabled + source={{uri: params.playerUri}} + onLoad={onLoad} + setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads) + style={[styles.webview, styles.topRadius]} /> - ) : ( - <View style={{height, width: '100%'}}> - <WebView - javaScriptEnabled={true} - onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} - mediaPlaybackRequiresUserAction={false} - allowsInlineMediaPlayback - bounces={false} - allowsFullscreenVideo - nestedScrollEnabled - source={{uri: params.playerUri}} - onLoad={onLoad} - setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads) - style={[styles.webview, styles.topRadius]} - /> - </View> - )} + </View> </EventStopper> </View> ) @@ -116,6 +118,10 @@ export function ExternalPlayer({ params: EmbedPlayerParams }) { const navigation = useNavigation<NavigationProp>() + const insets = useSafeAreaInsets() + const windowDims = useWindowDimensions() + const externalEmbedsPrefs = useExternalEmbedsPrefs() + const {openModal} = useModalControls() const [isPlayerActive, setPlayerActive] = React.useState(false) const [isLoading, setIsLoading] = React.useState(true) @@ -124,34 +130,51 @@ export function ExternalPlayer({ height: 0, }) - const viewRef = React.useRef<View>(null) + const viewRef = useAnimatedRef() + + const frameCallback = useFrameCallback(() => { + const measurement = measure(viewRef) + if (!measurement) return + + const {height: winHeight, width: winWidth} = windowDims + + // Get the proper screen height depending on what is going on + const realWinHeight = isNative // If it is native, we always want the larger number + ? winHeight > winWidth + ? winHeight + : winWidth + : winHeight // On web, we always want the actual screen height + + const top = measurement.pageY + const bot = measurement.pageY + measurement.height + + // We can use the same logic on all platforms against the screenHeight that we get above + const isVisible = top <= realWinHeight - insets.bottom && bot >= insets.top + + if (!isVisible) { + runOnJS(setPlayerActive)(false) + } + }, false) // False here disables autostarting the callback // watch for leaving the viewport due to scrolling React.useEffect(() => { + // We don't want to do anything if the player isn't active + if (!isPlayerActive) return + // Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will // continue playing. We need to watch for the blur event const unsubscribe = navigation.addListener('blur', () => { setPlayerActive(false) }) - const interval = setInterval(() => { - viewRef.current?.measure((x, y, w, h, pageX, pageY) => { - const window = Dimensions.get('window') - const top = pageY - const bot = pageY + h - const isVisible = isNative - ? top >= 0 && bot <= window.height - : !(top >= window.height || bot <= 0) - if (!isVisible) { - setPlayerActive(false) - } - }) - }, 1e3) + // Start watching for changes + frameCallback.setActive(true) + return () => { unsubscribe() - clearInterval(interval) + frameCallback.setActive(false) } - }, [viewRef, navigation]) + }, [navigation, isPlayerActive, frameCallback]) // calculate height for the player and the screen size const height = React.useMemo( @@ -168,12 +191,26 @@ export function ExternalPlayer({ setIsLoading(false) }, []) - const onPlayPress = React.useCallback((event: GestureResponderEvent) => { - // Prevent this from propagating upward on web - event.preventDefault() + const onPlayPress = React.useCallback( + (event: GestureResponderEvent) => { + // Prevent this from propagating upward on web + event.preventDefault() - setPlayerActive(true) - }, []) + if (externalEmbedsPrefs?.[params.source] === undefined) { + openModal({ + name: 'embed-consent', + source: params.source, + onAccept: () => { + setPlayerActive(true) + }, + }) + return + } + + setPlayerActive(true) + }, + [externalEmbedsPrefs, openModal, params.source], + ) // measure the layout to set sizing const onLayout = React.useCallback( @@ -187,7 +224,7 @@ export function ExternalPlayer({ ) return ( - <View + <Animated.View ref={viewRef} style={{height}} collapsable={false} @@ -205,7 +242,6 @@ export function ExternalPlayer({ accessibilityIgnoresInvertColors /> )} - <PlaceholderOverlay isLoading={isLoading} isPlayerActive={isPlayerActive} @@ -217,7 +253,7 @@ export function ExternalPlayer({ height={height} onLoad={onLoad} /> - </View> + </Animated.View> ) } @@ -248,4 +284,8 @@ const styles = StyleSheet.create({ webview: { backgroundColor: 'transparent', }, + gifContainer: { + width: '100%', + overflow: 'hidden', + }, }) diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index e793f983e..256817bba 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -6,6 +6,8 @@ import { AppBskyEmbedImages, AppBskyEmbedRecordWithMedia, ModerationUI, + AppBskyEmbedExternal, + RichText as RichTextAPI, } from '@atproto/api' import {AtUri} from '@atproto/api' import {PostMeta} from '../PostMeta' @@ -17,6 +19,8 @@ import {PostEmbeds} from '.' 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, @@ -41,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} @@ -52,7 +57,7 @@ export function MaybeQuoteEmbed({ <View style={[styles.errorContainer, pal.borderDark]}> <InfoCircleIcon size={18} style={pal.text} /> <Text type="lg" style={pal.text}> - Blocked + <Trans>Blocked</Trans> </Text> </View> ) @@ -61,7 +66,7 @@ export function MaybeQuoteEmbed({ <View style={[styles.errorContainer, pal.borderDark]}> <InfoCircleIcon size={18} style={pal.text} /> <Text type="lg" style={pal.text}> - Deleted + <Trans>Deleted</Trans> </Text> </View> ) @@ -82,22 +87,30 @@ 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 imagesEmbed = React.useMemo( + const richText = React.useMemo( () => - quote.embeds?.find( - embed => - AppBskyEmbedImages.isView(embed) || - AppBskyEmbedRecordWithMedia.isView(embed), - ), - [quote.embeds], + 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] + + if (AppBskyEmbedImages.isView(e) || AppBskyEmbedExternal.isView(e)) { + return e + } else if ( + AppBskyEmbedRecordWithMedia.isView(e) && + (AppBskyEmbedImages.isView(e.media) || + AppBskyEmbedExternal.isView(e.media)) + ) { + return e.media + } + }, [quote.embeds]) return ( <Link style={[styles.container, pal.borderDark, style]} + hoverStyle={{borderColor: pal.colors.borderLinkHover}} href={itemHref} title={itemTitle}> <PostMeta @@ -110,17 +123,16 @@ export function QuoteEmbed({ {moderation ? ( <PostAlerts moderation={moderation} style={styles.alert} /> ) : null} - {!isEmpty ? ( - <Text type="post-text" style={pal.text} numberOfLines={6}> - {quote.text} - </Text> + {richText ? ( + <RichText + richText={richText} + type="post-text" + style={pal.text} + numberOfLines={20} + noLinks + /> ) : null} - {AppBskyEmbedImages.isView(imagesEmbed) && ( - <PostEmbeds embed={imagesEmbed} moderation={{}} /> - )} - {AppBskyEmbedRecordWithMedia.isView(imagesEmbed) && ( - <PostEmbeds embed={imagesEmbed.media} moderation={{}} /> - )} + {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 c94ce9684..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 // = @@ -63,7 +61,7 @@ export function PostEmbeds({ const mediaModeration = isModOnQuote ? {} : moderation const quoteModeration = isModOnQuote ? moderation : {} return ( - <View style={[styles.stackContainer, style]}> + <View style={style}> <PostEmbeds embed={embed.media} moderation={mediaModeration} /> <ContentHider moderation={quoteModeration}> <MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} /> @@ -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> ) @@ -168,11 +159,14 @@ export function PostEmbeds({ const link = embed.external return ( - <View style={[styles.extOuter, pal.view, pal.border, style]}> - <Link asAnchor href={link.uri}> - <ExternalLinkEmbed link={link} /> - </Link> - </View> + <Link + asAnchor + anchorNoUnderline + href={link.uri} + style={[styles.extOuter, pal.view, pal.borderDark, style]} + hoverStyle={{borderColor: pal.colors.borderLinkHover}}> + <ExternalLinkEmbed link={link} /> + </Link> ) } @@ -180,18 +174,11 @@ export function PostEmbeds({ } const styles = StyleSheet.create({ - stackContainer: { - gap: 6, - }, imagesContainer: { marginTop: 8, }, 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> diff --git a/src/view/icons/Logo.tsx b/src/view/icons/Logo.tsx index 15ab5a11c..9212381a9 100644 --- a/src/view/icons/Logo.tsx +++ b/src/view/icons/Logo.tsx @@ -1,4 +1,5 @@ import React from 'react' +import {StyleSheet, TextProps} from 'react-native' import Svg, { Path, Defs, @@ -14,12 +15,14 @@ const ratio = 57 / 64 type Props = { fill?: PathProps['fill'] -} & SvgProps + style?: TextProps['style'] +} & Omit<SvgProps, 'style'> export const Logo = React.forwardRef(function LogoImpl(props: Props, ref) { const {fill, ...rest} = props const gradient = fill === 'sky' - const _fill = gradient ? 'url(#sky)' : fill || colors.blue3 + const styles = StyleSheet.flatten(props.style) + const _fill = gradient ? 'url(#sky)' : fill || styles?.color || colors.blue3 // @ts-ignore it's fiiiiine const size = parseInt(rest.width || 32) return ( @@ -29,7 +32,7 @@ export const Logo = React.forwardRef(function LogoImpl(props: Props, ref) { ref={ref} viewBox="0 0 64 57" {...rest} - style={{width: size, height: size * ratio}}> + style={[{width: size, height: size * ratio}, styles]}> {gradient && ( <Defs> <LinearGradient id="sky" x1="0" y1="0" x2="0" y2="1"> diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx index 089d3f0a8..be139d2f2 100644 --- a/src/view/icons/index.tsx +++ b/src/view/icons/index.tsx @@ -29,9 +29,10 @@ import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight' import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle' import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck' import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck' +import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot' import {faCircleExclamation} from '@fortawesome/free-solid-svg-icons/faCircleExclamation' +import {faCirclePlay} from '@fortawesome/free-regular-svg-icons/faCirclePlay' import {faCircleUser} from '@fortawesome/free-regular-svg-icons/faCircleUser' -import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot' import {faClone} from '@fortawesome/free-solid-svg-icons/faClone' import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone' import {faComment} from '@fortawesome/free-regular-svg-icons/faComment' @@ -51,6 +52,7 @@ import {faGear} from '@fortawesome/free-solid-svg-icons/faGear' import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' import {faHand} from '@fortawesome/free-solid-svg-icons/faHand' import {faHand as farHand} from '@fortawesome/free-regular-svg-icons/faHand' +import {faHashtag} from '@fortawesome/free-solid-svg-icons/faHashtag' import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart' import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart' import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse' @@ -70,6 +72,7 @@ import {faPaste} from '@fortawesome/free-regular-svg-icons/faPaste' import {faPen} from '@fortawesome/free-solid-svg-icons/faPen' import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib' import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare' +import {faPhone} from '@fortawesome/free-solid-svg-icons/faPhone' import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay' import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus' import {faQuoteLeft} from '@fortawesome/free-solid-svg-icons/faQuoteLeft' @@ -77,6 +80,7 @@ import {faReply} from '@fortawesome/free-solid-svg-icons/faReply' import {faRetweet} from '@fortawesome/free-solid-svg-icons/faRetweet' import {faRss} from '@fortawesome/free-solid-svg-icons/faRss' import {faSatelliteDish} from '@fortawesome/free-solid-svg-icons/faSatelliteDish' +import {faServer} from '@fortawesome/free-solid-svg-icons/faServer' import {faShare} from '@fortawesome/free-solid-svg-icons/faShare' import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare' import {faShield} from '@fortawesome/free-solid-svg-icons/faShield' @@ -129,9 +133,10 @@ library.add( faCircle, faCircleCheck, farCircleCheck, + faCircleDot, faCircleExclamation, + faCirclePlay, faCircleUser, - faCircleDot, faClone, farClone, faComment, @@ -151,6 +156,7 @@ library.add( faGlobe, faHand, farHand, + faHashtag, faHeart, fasHeart, faHouse, @@ -170,6 +176,7 @@ library.add( faPen, faPenNib, faPenToSquare, + faPhone, faPlay, faPlus, faQuoteLeft, @@ -177,6 +184,7 @@ library.add( faRetweet, faRss, faSatelliteDish, + faServer, faShare, faShareFromSquare, faShield, diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index 154035f22..dc439c367 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -33,6 +33,7 @@ import {cleanError} from '#/lib/strings/errors' type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'> export function AppPasswords({}: Props) { const pal = usePalette('default') + const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() const {screen} = useAnalytics() const {isTabletOrDesktop} = useWebMediaQueries() @@ -61,8 +62,8 @@ export function AppPasswords({}: Props) { ]} testID="appPasswordsScreen"> <ErrorScreen - title="Oops!" - message="There was an issue with fetching your app passwords" + title={_(msg`Oops!`)} + message={_(msg`There was an issue with fetching your app passwords`)} details={cleanError(error)} /> </CenteredView> @@ -98,7 +99,7 @@ export function AppPasswords({}: Props) { <Button testID="appPasswordBtn" type="primary" - label="Add App Password" + label={_(msg`Add App Password`)} style={styles.btn} labelStyle={styles.btnLabel} onPress={onAdd} @@ -139,7 +140,7 @@ export function AppPasswords({}: Props) { <Button testID="appPasswordBtn" type="primary" - label="Add App Password" + label={_(msg`Add App Password`)} style={styles.btn} labelStyle={styles.btnLabel} onPress={onAdd} @@ -152,7 +153,7 @@ export function AppPasswords({}: Props) { <Button testID="appPasswordBtn" type="primary" - label="Add App Password" + label={_(msg`Add App Password`)} style={styles.btn} labelStyle={styles.btnLabel} onPress={onAdd} @@ -224,7 +225,7 @@ function AppPassword({ ), async onPressConfirm() { await deleteMutation.mutateAsync({name}) - Toast.show('App password deleted') + Toast.show(_(msg`App password deleted`)) }, }) }, [deleteMutation, openModal, name, _]) diff --git a/src/view/screens/Debug.tsx b/src/view/screens/Debug.tsx index 0e0464200..f26b1505a 100644 --- a/src/view/screens/Debug.tsx +++ b/src/view/screens/Debug.tsx @@ -16,6 +16,8 @@ import {ToggleButton} from '../com/util/forms/ToggleButton' import {RadioGroup} from '../com/util/forms/RadioGroup' import {ErrorScreen} from '../com/util/error/ErrorScreen' import {ErrorMessage} from '../com/util/error/ErrorMessage' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' const MAIN_VIEWS = ['Base', 'Controls', 'Error', 'Notifs'] @@ -48,6 +50,7 @@ function DebugInner({ }) { const [currentView, setCurrentView] = React.useState<number>(0) const pal = usePalette('default') + const {_} = useLingui() const renderItem = (item: any) => { return ( @@ -57,7 +60,7 @@ function DebugInner({ type="default-light" onPress={onToggleColorScheme} isSelected={colorScheme === 'dark'} - label="Dark mode" + label={_(msg`Dark mode`)} /> </View> {item.currentView === 3 ? ( @@ -77,7 +80,7 @@ function DebugInner({ return ( <View style={[s.hContentRegion, pal.view]}> - <ViewHeader title="Debug panel" /> + <ViewHeader title={_(msg`Debug panel`)} /> <ViewSelector swipeEnabled sections={MAIN_VIEWS} diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 20cdf815a..a913364d4 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -97,6 +97,7 @@ export function FeedsScreen(_props: Props) { data: preferences, isLoading: isPreferencesLoading, error: preferencesError, + refetch: refetchPreferences, } = usePreferencesQuery() const { data: popularFeeds, @@ -151,9 +152,12 @@ export function FeedsScreen(_props: Props) { }, [query, debouncedSearch]) const onPullToRefresh = React.useCallback(async () => { setIsPTR(true) - await refetchPopularFeeds() + await Promise.all([ + refetchPreferences().catch(_e => undefined), + refetchPopularFeeds().catch(_e => undefined), + ]) setIsPTR(false) - }, [setIsPTR, refetchPopularFeeds]) + }, [setIsPTR, refetchPreferences, refetchPopularFeeds]) const onEndReached = React.useCallback(() => { if ( isPopularFeedsFetching || @@ -328,7 +332,7 @@ export function FeedsScreen(_props: Props) { hitSlop={10} accessibilityRole="button" accessibilityLabel={_(msg`Edit Saved Feeds`)} - accessibilityHint="Opens screen to edit Saved Feeds"> + accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}> <CogIcon size={22} strokeWidth={2} style={pal.textLight} /> </Link> ) @@ -494,6 +498,8 @@ export function FeedsScreen(_props: Props) { // @ts-ignore our .web version only -prf desktopFixedHeight scrollIndicatorInsets={{right: 1}} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" /> {hasSession && ( diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index b8033f0b4..7d6a40f02 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -109,7 +109,9 @@ function HomeScreenReady({ const homeFeedParams = React.useMemo<FeedParams>(() => { return { mergeFeedEnabled: Boolean(preferences.feedViewPrefs.lab_mergeFeedEnabled), - mergeFeedSources: preferences.feeds.saved, + mergeFeedSources: preferences.feedViewPrefs.lab_mergeFeedEnabled + ? preferences.feeds.saved + : [], } }, [preferences]) diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx index d28db7c6c..bdd5dd9b7 100644 --- a/src/view/screens/Lists.tsx +++ b/src/view/screens/Lists.tsx @@ -61,7 +61,7 @@ export function ListsScreen({}: Props) { <Trans>Public, shareable lists which can drive feeds.</Trans> </Text> </View> - <View> + <View style={[{marginLeft: 18}, isMobile && {marginLeft: 12}]}> <Button testID="newUserListBtn" type="default" @@ -73,7 +73,7 @@ export function ListsScreen({}: Props) { }}> <FontAwesomeIcon icon="plus" color={pal.colors.text} /> <Text type="button" style={pal.text}> - <Trans>New</Trans> + <Trans context="action">New</Trans> </Text> </Button> </View> diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx index 8680b851b..e727a1fb8 100644 --- a/src/view/screens/Log.tsx +++ b/src/view/screens/Log.tsx @@ -50,7 +50,9 @@ export function LogScreen({}: NativeStackScreenProps< style={[styles.entry, pal.border, pal.view]} onPress={toggler(entry.id)} accessibilityLabel={_(msg`View debug entry`)} - accessibilityHint="Opens additional details for a debug entry"> + accessibilityHint={_( + msg`Opens additional details for a debug entry`, + )}> {entry.level === 'debug' ? ( <FontAwesomeIcon icon="info" /> ) : ( diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx index 1bf8db2e0..96bb46cef 100644 --- a/src/view/screens/Moderation.tsx +++ b/src/view/screens/Moderation.tsx @@ -62,7 +62,7 @@ export function ModerationScreen({}: Props) { ]} testID="moderationScreen"> <ViewHeader title={_(msg`Moderation`)} showOnDesktop /> - <ScrollView> + <ScrollView contentContainerStyle={[styles.noBorder]}> <View style={styles.spacer} /> <TouchableOpacity testID="contentFilteringBtn" @@ -275,4 +275,10 @@ const styles = StyleSheet.create({ borderRadius: 30, marginRight: 12, }, + noBorder: { + borderBottomWidth: 0, + borderRightWidth: 0, + borderLeftWidth: 0, + borderTopWidth: 0, + }, }) diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx index d6a3b5f6f..b7d993acc 100644 --- a/src/view/screens/ModerationModlists.tsx +++ b/src/view/screens/ModerationModlists.tsx @@ -63,7 +63,7 @@ export function ModerationModlistsScreen({}: Props) { </Trans> </Text> </View> - <View> + <View style={[{marginLeft: 18}, isMobile && {marginLeft: 12}]}> <Button testID="newModListBtn" type="default" diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index 9f50c8b73..276dc842c 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -25,6 +25,7 @@ import {ErrorMessage} from '../com/util/error/ErrorMessage' import {CenteredView} from '../com/util/Views' import {useComposerControls} from '#/state/shell/composer' import {useSession} from '#/state/session' +import {isWeb} from '#/platform/detection' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'> export function PostThreadScreen({route}: Props) { @@ -67,6 +68,7 @@ export function PostThreadScreen({route}: Props) { displayName: thread.post.author.displayName, avatar: thread.post.author.avatar, }, + embed: thread.post.embed, }, onPost: () => queryClient.invalidateQueries({ @@ -77,7 +79,9 @@ export function PostThreadScreen({route}: Props) { return ( <View style={s.hContentRegion}> - {isMobile && <ViewHeader title={_(msg`Post`)} />} + {isMobile && ( + <ViewHeader title={_(msg({message: 'Post', context: 'description'}))} /> + )} <View style={s.flex1}> {uriError ? ( <CenteredView> @@ -109,7 +113,8 @@ export function PostThreadScreen({route}: Props) { const styles = StyleSheet.create({ prompt: { - position: 'absolute', + // @ts-ignore web-only + position: isWeb ? 'fixed' : 'absolute', left: 0, right: 0, }, diff --git a/src/view/screens/PreferencesExternalEmbeds.tsx b/src/view/screens/PreferencesExternalEmbeds.tsx new file mode 100644 index 000000000..1e8cedf7e --- /dev/null +++ b/src/view/screens/PreferencesExternalEmbeds.tsx @@ -0,0 +1,138 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {s} from 'lib/styles' +import {Text} from '../com/util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics/analytics' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import { + EmbedPlayerSource, + externalEmbedLabels, +} from '#/lib/strings/embed-player' +import {useSetMinimalShellMode} from '#/state/shell' +import {Trans} from '@lingui/macro' +import {ScrollView} from '../com/util/Views' +import { + useExternalEmbedsPrefs, + useSetExternalEmbedPref, +} from 'state/preferences' +import {ToggleButton} from 'view/com/util/forms/ToggleButton' +import {SimpleViewHeader} from '../com/util/SimpleViewHeader' + +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'PreferencesExternalEmbeds' +> +export function PreferencesExternalEmbeds({}: Props) { + const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() + const {screen} = useAnalytics() + const {isMobile} = useWebMediaQueries() + + useFocusEffect( + React.useCallback(() => { + screen('PreferencesExternalEmbeds') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) + + return ( + <View style={s.hContentRegion} testID="preferencesExternalEmbedsScreen"> + <SimpleViewHeader + showBackButton={isMobile} + style={[ + pal.border, + {borderBottomWidth: 1}, + !isMobile && {borderLeftWidth: 1, borderRightWidth: 1}, + ]}> + <View style={{flex: 1}}> + <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> + <Trans>External Media Preferences</Trans> + </Text> + <Text style={pal.textLight}> + <Trans>Customize media from external sites.</Trans> + </Text> + </View> + </SimpleViewHeader> + <ScrollView + // @ts-ignore web only -prf + dataSet={{'stable-gutters': 1}} + contentContainerStyle={[pal.viewLight, {paddingBottom: 200}]}> + <View style={[pal.view]}> + <View style={styles.infoCard}> + <Text style={pal.text}> + <Trans> + External media may allow websites to collect information about + you and your device. No information is sent or requested until + you press the "play" button. + </Trans> + </Text> + </View> + </View> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Enable media players for</Trans> + </Text> + {Object.entries(externalEmbedLabels).map(([key, label]) => ( + <PrefSelector + source={key as EmbedPlayerSource} + label={label} + key={key} + /> + ))} + </ScrollView> + </View> + ) +} + +function PrefSelector({ + source, + label, +}: { + source: EmbedPlayerSource + label: string +}) { + const pal = usePalette('default') + const setExternalEmbedPref = useSetExternalEmbedPref() + const sources = useExternalEmbedsPrefs() + + return ( + <View> + <View style={[pal.view, styles.toggleCard]}> + <ToggleButton + type="default-light" + label={label} + labelType="lg" + isSelected={sources?.[source] === 'show'} + onPress={() => + setExternalEmbedPref( + source, + sources?.[source] === 'show' ? 'hide' : 'show', + ) + } + /> + </View> + </View> + ) +} + +const styles = StyleSheet.create({ + heading: { + paddingHorizontal: 18, + paddingTop: 14, + paddingBottom: 14, + }, + spacer: { + height: 8, + }, + infoCard: { + paddingHorizontal: 20, + paddingVertical: 14, + }, + toggleCard: { + paddingVertical: 8, + paddingHorizontal: 6, + marginBottom: 1, + }, +}) diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx index 20ef72923..7ad870937 100644 --- a/src/view/screens/PreferencesHomeFeed.tsx +++ b/src/view/screens/PreferencesHomeFeed.tsx @@ -27,8 +27,10 @@ function RepliesThresholdInput({ initialValue: number }) { const pal = usePalette('default') + const {_} = useLingui() const [value, setValue] = useState(initialValue) const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation() + const preValue = React.useRef(initialValue) const save = React.useMemo( () => debounce( @@ -46,7 +48,12 @@ function RepliesThresholdInput({ <Slider value={value} onValueChange={(v: number | number[]) => { - const threshold = Math.floor(Array.isArray(v) ? v[0] : v) + let threshold = Array.isArray(v) ? v[0] : v + if (threshold > preValue.current) threshold = Math.floor(threshold) + else threshold = Math.ceil(threshold) + + preValue.current = threshold + setValue(threshold) save(threshold) }} @@ -58,10 +65,12 @@ function RepliesThresholdInput({ /> <Text type="xs" style={pal.text}> {value === 0 - ? `Show all replies` - : `Show replies with at least ${value} ${ - value > 1 ? `likes` : `like` - }`} + ? _(msg`Show all replies`) + : _( + msg`Show replies with at least ${value} ${ + value > 1 ? `likes` : `like` + }`, + )} </Text> </View> ) diff --git a/src/view/screens/PreferencesThreads.tsx b/src/view/screens/PreferencesThreads.tsx index 73d941932..321c67293 100644 --- a/src/view/screens/PreferencesThreads.tsx +++ b/src/view/screens/PreferencesThreads.tsx @@ -75,10 +75,16 @@ export function PreferencesThreads({navigation}: Props) { <RadioGroup type="default-light" items={[ - {key: 'oldest', label: 'Oldest replies first'}, - {key: 'newest', label: 'Newest replies first'}, - {key: 'most-likes', label: 'Most-liked replies first'}, - {key: 'random', label: 'Random (aka "Poster\'s Roulette")'}, + {key: 'oldest', label: _(msg`Oldest replies first`)}, + {key: 'newest', label: _(msg`Newest replies first`)}, + { + key: 'most-likes', + label: _(msg`Most-liked replies first`), + }, + { + key: 'random', + label: _(msg`Random (aka "Poster's Roulette")`), + }, ]} onSelect={key => setThreadViewPrefs({sort: key})} initialSelection={preferences?.threadViewPrefs?.sort} @@ -97,7 +103,7 @@ export function PreferencesThreads({navigation}: Props) { </Text> <ToggleButton type="default-light" - label={prioritizeFollowedUsers ? 'Yes' : 'No'} + label={prioritizeFollowedUsers ? _(msg`Yes`) : _(msg`No`)} isSelected={prioritizeFollowedUsers} onPress={() => setThreadViewPrefs({ @@ -120,7 +126,7 @@ export function PreferencesThreads({navigation}: Props) { </Text> <ToggleButton type="default-light" - label={treeViewEnabled ? 'Yes' : 'No'} + label={treeViewEnabled ? _(msg`Yes`) : _(msg`No`)} isSelected={treeViewEnabled} onPress={() => setThreadViewPrefs({ @@ -153,7 +159,7 @@ export function PreferencesThreads({navigation}: Props) { accessibilityLabel={_(msg`Confirm`)} accessibilityHint=""> <Text style={[s.white, s.bold, s.f18]}> - <Trans>Done</Trans> + <Trans context="action">Done</Trans> </Text> </TouchableOpacity> </View> diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 4558ae33d..7fc4d7a20 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -371,6 +371,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor}, ref, ) { + const {_} = useLingui() const queryClient = useQueryClient() const [hasNew, setHasNew] = React.useState(false) const [isScrolledDown, setIsScrolledDown] = React.useState(false) @@ -388,8 +389,8 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( })) const renderPostsEmpty = React.useCallback(() => { - return <EmptyState icon="feed" message="This feed is empty!" /> - }, []) + return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} /> + }, [_]) return ( <View> @@ -408,7 +409,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( {(isScrolledDown || hasNew) && ( <LoadLatestBtn onPress={onScrollToTop} - label="Load new posts" + label={_(msg`Load new posts`)} showIndicator={hasNew} /> )} diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx index 211306c0d..61282497c 100644 --- a/src/view/screens/ProfileFeed.tsx +++ b/src/view/screens/ProfileFeed.tsx @@ -214,11 +214,21 @@ export function ProfileFeedScreenInner({ } } catch (err) { Toast.show( - 'There was an an issue updating your feeds, please check your internet connection and try again.', + _( + msg`There was an an issue updating your feeds, please check your internet connection and try again.`, + ), ) logger.error('Failed up update feeds', {error: err}) } - }, [feedInfo, isSaved, saveFeed, removeFeed, resetSaveFeed, resetRemoveFeed]) + }, [ + feedInfo, + isSaved, + saveFeed, + removeFeed, + resetSaveFeed, + resetRemoveFeed, + _, + ]) const onTogglePinned = React.useCallback(async () => { try { @@ -232,10 +242,10 @@ export function ProfileFeedScreenInner({ resetPinFeed() } } catch (e) { - Toast.show('There was an issue contacting the server') + Toast.show(_(msg`There was an issue contacting the server`)) logger.error('Failed to toggle pinned feed', {error: e}) } - }, [isPinned, feedInfo, pinFeed, unpinFeed, resetPinFeed, resetUnpinFeed]) + }, [isPinned, feedInfo, pinFeed, unpinFeed, resetPinFeed, resetUnpinFeed, _]) const onPressShare = React.useCallback(() => { const url = toShareUrl(feedInfo.route.href) @@ -341,7 +351,7 @@ export function ProfileFeedScreenInner({ <Button disabled={isSavePending || isRemovePending} type="default" - label={isSaved ? 'Unsave' : 'Save'} + label={isSaved ? _(msg`Unsave`) : _(msg`Save`)} onPress={onToggleSaved} style={styles.btn} /> @@ -349,7 +359,7 @@ export function ProfileFeedScreenInner({ testID={isPinned ? 'unpinBtn' : 'pinBtn'} disabled={isPinPending || isUnpinPending} type={isPinned ? 'default' : 'inverted'} - label={isPinned ? 'Unpin' : 'Pin to home'} + label={isPinned ? _(msg`Unpin`) : _(msg`Pin to home`)} onPress={onTogglePinned} style={styles.btn} /> @@ -444,6 +454,7 @@ interface FeedSectionProps { } const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( function FeedSectionImpl({feed, headerHeight, scrollElRef, isFocused}, ref) { + const {_} = useLingui() const [hasNew, setHasNew] = React.useState(false) const [isScrolledDown, setIsScrolledDown] = React.useState(false) const queryClient = useQueryClient() @@ -470,8 +481,8 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( }, [onScrollToTop, isScreenFocused]) const renderPostsEmpty = useCallback(() => { - return <EmptyState icon="feed" message="This feed is empty!" /> - }, []) + return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} /> + }, [_]) return ( <View> @@ -479,6 +490,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( enabled={isFocused} feed={feed} pollInterval={60e3} + disablePoll={hasNew} scrollElRef={scrollElRef} onHasNew={setHasNew} onScrolledDownChange={setIsScrolledDown} @@ -488,7 +500,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( {(isScrolledDown || hasNew) && ( <LoadLatestBtn onPress={onScrollToTop} - label="Load new posts" + label={_(msg`Load new posts`)} showIndicator={hasNew} /> )} @@ -542,11 +554,13 @@ function AboutSection({ } } catch (err) { Toast.show( - 'There was an an issue contacting the server, please check your internet connection and try again.', + _( + msg`There was an an issue contacting the server, please check your internet connection and try again.`, + ), ) logger.error('Failed up toggle like', {error: err}) } - }, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed, track]) + }, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed, track, _]) return ( <ScrollView @@ -597,24 +611,28 @@ function AboutSection({ {typeof likeCount === 'number' && ( <TextLink href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')} - text={`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`} + text={_( + msg`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`, + )} style={[pal.textLight, s.semiBold]} /> )} </View> <Text type="md" style={[pal.textLight]} numberOfLines={1}> - Created by{' '} {isOwner ? ( - 'you' + <Trans>Created by you</Trans> ) : ( - <TextLink - text={sanitizeHandle(feedInfo.creatorHandle, '@')} - href={makeProfileLink({ - did: feedInfo.creatorDid, - handle: feedInfo.creatorHandle, - })} - style={pal.textLight} - /> + <Trans> + Created by{' '} + <TextLink + text={sanitizeHandle(feedInfo.creatorHandle, '@')} + href={makeProfileLink({ + did: feedInfo.creatorDid, + handle: feedInfo.creatorHandle, + })} + style={pal.textLight} + /> + </Trans> )} </Text> </View> diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index c51758ae5..cb7962a9b 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -68,6 +68,7 @@ interface SectionRef { type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'> export function ProfileListScreen(props: Props) { + const {_} = useLingui() const {name: handleOrDid, rkey} = props.route.params const {data: resolvedUri, error: resolveError} = useResolveUriQuery( AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(), @@ -78,7 +79,9 @@ export function ProfileListScreen(props: Props) { return ( <CenteredView> <ErrorScreen - error={`We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`} + error={_( + msg`We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`, + )} /> </CenteredView> ) @@ -260,10 +263,10 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { await pinFeed({uri: list.uri}) } } catch (e) { - Toast.show('There was an issue contacting the server') + Toast.show(_(msg`There was an issue contacting the server`)) logger.error('Failed to toggle pinned feed', {error: e}) } - }, [list.uri, isPinned, pinFeed, unpinFeed]) + }, [list.uri, isPinned, pinFeed, unpinFeed, _]) const onSubscribeMute = useCallback(() => { openModal({ @@ -272,15 +275,17 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { message: _( msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`, ), - confirmBtnText: 'Mute this List', + confirmBtnText: _(msg`Mute this List`), async onPressConfirm() { try { await listMuteMutation.mutateAsync({uri: list.uri, mute: true}) - Toast.show('List muted') + Toast.show(_(msg`List muted`)) track('Lists:Mute') } catch { Toast.show( - 'There was an issue. Please check your internet connection and try again.', + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), ) } }, @@ -293,14 +298,16 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const onUnsubscribeMute = useCallback(async () => { try { await listMuteMutation.mutateAsync({uri: list.uri, mute: false}) - Toast.show('List unmuted') + Toast.show(_(msg`List unmuted`)) track('Lists:Unmute') } catch { Toast.show( - 'There was an issue. Please check your internet connection and try again.', + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), ) } - }, [list, listMuteMutation, track]) + }, [list, listMuteMutation, track, _]) const onSubscribeBlock = useCallback(() => { openModal({ @@ -309,15 +316,17 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { message: _( msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, ), - confirmBtnText: 'Block this List', + confirmBtnText: _(msg`Block this List`), async onPressConfirm() { try { await listBlockMutation.mutateAsync({uri: list.uri, block: true}) - Toast.show('List blocked') + Toast.show(_(msg`List blocked`)) track('Lists:Block') } catch { Toast.show( - 'There was an issue. Please check your internet connection and try again.', + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), ) } }, @@ -330,14 +339,16 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const onUnsubscribeBlock = useCallback(async () => { try { await listBlockMutation.mutateAsync({uri: list.uri, block: false}) - Toast.show('List unblocked') + Toast.show(_(msg`List unblocked`)) track('Lists:Unblock') } catch { Toast.show( - 'There was an issue. Please check your internet connection and try again.', + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), ) } - }, [list, listBlockMutation, track]) + }, [list, listBlockMutation, track, _]) const onPressEdit = useCallback(() => { openModal({ @@ -353,7 +364,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { message: _(msg`Are you sure?`), async onPressConfirm() { await listDeleteMutation.mutateAsync({uri: list.uri}) - Toast.show('List deleted') + Toast.show(_(msg`List deleted`)) track('Lists:Delete') if (navigation.canGoBack()) { navigation.goBack() @@ -545,7 +556,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { <Button testID={isPinned ? 'unpinBtn' : 'pinBtn'} type={isPinned ? 'default' : 'inverted'} - label={isPinned ? 'Unpin' : 'Pin to home'} + label={isPinned ? _(msg`Unpin`) : _(msg`Pin to home`)} onPress={onTogglePinned} disabled={isPending} /> @@ -554,14 +565,14 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { <Button testID="unblockBtn" type="default" - label="Unblock" + label={_(msg`Unblock`)} onPress={onUnsubscribeBlock} /> ) : isMuting ? ( <Button testID="unmuteBtn" type="default" - label="Unmute" + label={_(msg`Unmute`)} onPress={onUnsubscribeMute} /> ) : ( @@ -603,6 +614,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( const [hasNew, setHasNew] = React.useState(false) const [isScrolledDown, setIsScrolledDown] = React.useState(false) const isScreenFocused = useIsFocused() + const {_} = useLingui() const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({ @@ -624,8 +636,8 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( }, [onScrollToTop, isScreenFocused]) const renderPostsEmpty = useCallback(() => { - return <EmptyState icon="feed" message="This feed is empty!" /> - }, []) + return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} /> + }, [_]) return ( <View> @@ -634,6 +646,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( enabled={isFocused} feed={feed} pollInterval={60e3} + disablePoll={hasNew} scrollElRef={scrollElRef} onHasNew={setHasNew} onScrolledDownChange={setIsScrolledDown} @@ -643,7 +656,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( {(isScrolledDown || hasNew) && ( <LoadLatestBtn onPress={onScrollToTop} - label="Load new posts" + label={_(msg`Load new posts`)} showIndicator={hasNew} /> )} @@ -721,15 +734,30 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( </Text> )} <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {isCurateList ? 'User list' : 'Moderation list'} by{' '} - {isOwner ? ( - 'you' + {isCurateList ? ( + isOwner ? ( + <Trans>User list by you</Trans> + ) : ( + <Trans> + User list by{' '} + <TextLink + text={sanitizeHandle(list.creator.handle || '', '@')} + href={makeProfileLink(list.creator)} + style={pal.textLight} + /> + </Trans> + ) + ) : isOwner ? ( + <Trans>Moderation list by you</Trans> ) : ( - <TextLink - text={sanitizeHandle(list.creator.handle || '', '@')} - href={makeProfileLink(list.creator)} - style={pal.textLight} - /> + <Trans> + Moderation list by{' '} + <TextLink + text={sanitizeHandle(list.creator.handle || '', '@')} + href={makeProfileLink(list.creator)} + style={pal.textLight} + /> + </Trans> )} </Text> </View> @@ -782,11 +810,11 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( return ( <EmptyState icon="users-slash" - message="This list is empty!" + message={_(msg`This list is empty!`)} style={{paddingTop: 40}} /> ) - }, []) + }, [_]) return ( <View> @@ -802,7 +830,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( {isScrolledDown && ( <LoadLatestBtn onPress={onScrollToTop} - label="Scroll to top" + label={_(msg`Scroll to top`)} showIndicator={false} /> )} @@ -846,7 +874,7 @@ function ErrorScreen({error}: {error: string}) { <Button type="default" accessibilityLabel={_(msg`Go Back`)} - accessibilityHint="Return to previous page" + accessibilityHint={_(msg`Return to previous page`)} onPress={onPressBack} style={{flexShrink: 1}}> <Text type="button" style={pal.text}> diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index bbac30689..19ae37f0c 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -82,7 +82,7 @@ export function SavedFeeds({}: Props) { isTabletOrDesktop && styles.desktopContainer, ]}> <ViewHeader title={_(msg`Edit My Feeds`)} showOnDesktop showBorder /> - <ScrollView style={s.flex1}> + <ScrollView style={s.flex1} contentContainerStyle={[styles.noBorder]}> <View style={[pal.text, pal.border, styles.title]}> <Text type="title" style={pal.text}> <Trans>Pinned Feeds</Trans> @@ -160,7 +160,7 @@ export function SavedFeeds({}: Props) { type="sm" style={pal.link} href="https://github.com/bluesky-social/feed-generator" - text="See this guide" + text={_(msg`See this guide`)} />{' '} for more information. </Trans> @@ -188,6 +188,7 @@ function ListItem({ >['reset'] }) { const pal = usePalette('default') + const {_} = useLingui() const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation() const {isPending: isUnpinPending, mutateAsync: unpinFeed} = useUnpinFeedMutation() @@ -205,10 +206,10 @@ function ListItem({ await pinFeed({uri: feedUri}) } } catch (e) { - Toast.show('There was an issue contacting the server') + Toast.show(_(msg`There was an issue contacting the server`)) logger.error('Failed to toggle pinned feed', {error: e}) } - }, [feedUri, isPinned, pinFeed, unpinFeed, resetSaveFeedsMutationState]) + }, [feedUri, isPinned, pinFeed, unpinFeed, resetSaveFeedsMutationState, _]) const onPressUp = React.useCallback(async () => { if (!isPinned) return @@ -227,10 +228,10 @@ function ListItem({ index: pinned.indexOf(feedUri), }) } catch (e) { - Toast.show('There was an issue contacting the server') + Toast.show(_(msg`There was an issue contacting the server`)) logger.error('Failed to set pinned feed order', {error: e}) } - }, [feedUri, isPinned, setSavedFeeds, currentFeeds]) + }, [feedUri, isPinned, setSavedFeeds, currentFeeds, _]) const onPressDown = React.useCallback(async () => { if (!isPinned) return @@ -248,10 +249,10 @@ function ListItem({ index: pinned.indexOf(feedUri), }) } catch (e) { - Toast.show('There was an issue contacting the server') + Toast.show(_(msg`There was an issue contacting the server`)) logger.error('Failed to set pinned feed order', {error: e}) } - }, [feedUri, isPinned, setSavedFeeds, currentFeeds]) + }, [feedUri, isPinned, setSavedFeeds, currentFeeds, _]) return ( <Pressable @@ -288,7 +289,7 @@ function ListItem({ <FeedSourceCard key={feedUri} feedUri={feedUri} - style={styles.noBorder} + style={styles.noTopBorder} showSaveBtn showMinimalPlaceholder /> @@ -344,7 +345,7 @@ const styles = StyleSheet.create({ webArrowUpButton: { marginBottom: 10, }, - noBorder: { + noTopBorder: { borderTopWidth: 0, }, footerText: { @@ -352,4 +353,10 @@ const styles = StyleSheet.create({ paddingTop: 22, paddingBottom: 100, }, + noBorder: { + borderBottomWidth: 0, + borderRightWidth: 0, + borderLeftWidth: 0, + borderTopWidth: 0, + }, }) diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index b522edfba..df64cc5aa 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -42,11 +42,17 @@ import {useSetDrawerOpen} from '#/state/shell' import {useAnalytics} from '#/lib/analytics/analytics' import {MagnifyingGlassIcon} from '#/lib/icons' import {useModerationOpts} from '#/state/queries/preferences' -import {SearchResultCard} from '#/view/shell/desktop/Search' +import { + MATCH_HANDLE, + SearchLinkCard, + SearchProfileCard, +} from '#/view/shell/desktop/Search' import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' -import {isWeb} from '#/platform/detection' +import {isNative, isWeb} from '#/platform/detection' import {listenSoftReset} from '#/state/events' import {s} from '#/lib/styles' +import AsyncStorage from '@react-native-async-storage/async-storage' +import {augmentSearchQuery} from '#/lib/strings/helpers' function Loader() { const pal = usePalette('default') @@ -83,9 +89,7 @@ function EmptyState({message, error}: {message: string; error?: string}) { }, ]}> <View style={[pal.viewLight, {padding: 18, borderRadius: 8}]}> - <Text style={[pal.text]}> - <Trans>{message}</Trans> - </Text> + <Text style={[pal.text]}>{message}</Text> {error && ( <> @@ -162,6 +166,8 @@ function SearchScreenSuggestedFollows() { // @ts-ignore web only -prf desktopFixedHeight contentContainerStyle={{paddingBottom: 1200}} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" /> ) : ( <CenteredView sideBorders style={[pal.border, s.hContentRegion]}> @@ -303,13 +309,23 @@ function SearchScreenUserResults({query}: {query: string}) { const SECTIONS_LOGGEDOUT = ['Users'] const SECTIONS_LOGGEDIN = ['Posts', 'Users'] -export function SearchScreenInner({query}: {query?: string}) { +export function SearchScreenInner({ + query, + primarySearch, +}: { + query?: string + primarySearch?: boolean +}) { const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() - const {hasSession} = useSession() + const {hasSession, currentAccount} = useSession() const {isDesktop} = useWebMediaQueries() + const augmentedQuery = React.useMemo(() => { + return augmentSearchQuery(query || '', {did: currentAccount?.did}) + }, [query, currentAccount]) + const onPageSelected = React.useCallback( (index: number) => { setMinimalShellMode(false) @@ -324,13 +340,15 @@ export function SearchScreenInner({query}: {query?: string}) { tabBarPosition="top" onPageSelected={onPageSelected} renderTabBar={props => ( - <CenteredView sideBorders style={pal.border}> + <CenteredView + sideBorders + style={[pal.border, pal.view, styles.tabBarContainer]}> <TabBar items={SECTIONS_LOGGEDIN} {...props} /> </CenteredView> )} initialPage={0}> <View> - <SearchScreenPostResults query={query} /> + <SearchScreenPostResults query={augmentedQuery} /> </View> <View> <SearchScreenUserResults query={query} /> @@ -365,7 +383,9 @@ export function SearchScreenInner({query}: {query?: string}) { tabBarPosition="top" onPageSelected={onPageSelected} renderTabBar={props => ( - <CenteredView sideBorders style={pal.border}> + <CenteredView + sideBorders + style={[pal.border, pal.view, styles.tabBarContainer]}> <TabBar items={SECTIONS_LOGGEDOUT} {...props} /> </CenteredView> )} @@ -413,7 +433,7 @@ export function SearchScreenInner({query}: {query?: string}) { style={pal.textLight} /> <Text type="xl" style={[pal.textLight, {paddingHorizontal: 18}]}> - {isDesktop ? ( + {isDesktop && !primarySearch ? ( <Trans>Find users with the search tool on the right</Trans> ) : ( <Trans>Find users on Bluesky</Trans> @@ -425,19 +445,7 @@ export function SearchScreenInner({query}: {query?: string}) { ) } -export function SearchScreenDesktop( - props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, -) { - const {isDesktop} = useWebMediaQueries() - - return isDesktop ? ( - <SearchScreenInner query={props.route.params?.q} /> - ) : ( - <SearchScreenMobile {...props} /> - ) -} - -export function SearchScreenMobile( +export function SearchScreen( props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, ) { const theme = useTheme() @@ -449,7 +457,7 @@ export function SearchScreenMobile( const moderationOpts = useModerationOpts() const search = useActorAutocompleteFn() const setMinimalShellMode = useSetMinimalShellMode() - const {isTablet} = useWebMediaQueries() + const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries() const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>( undefined, @@ -462,32 +470,56 @@ export function SearchScreenMobile( const [inputIsFocused, setInputIsFocused] = React.useState(false) const [showAutocompleteResults, setShowAutocompleteResults] = React.useState(false) + const [searchHistory, setSearchHistory] = React.useState<string[]>([]) + + React.useEffect(() => { + const loadSearchHistory = async () => { + try { + const history = await AsyncStorage.getItem('searchHistory') + if (history !== null) { + setSearchHistory(JSON.parse(history)) + } + } catch (e: any) { + logger.error('Failed to load search history', e) + } + } + + loadSearchHistory() + }, []) const onPressMenu = React.useCallback(() => { track('ViewHeader:MenuButtonClicked') setDrawerOpen(true) }, [track, setDrawerOpen]) + const onPressCancelSearch = React.useCallback(() => { + scrollToTopWeb() textInput.current?.blur() setQuery('') setShowAutocompleteResults(false) if (searchDebounceTimeout.current) clearTimeout(searchDebounceTimeout.current) }, [textInput]) + const onPressClearQuery = React.useCallback(() => { + scrollToTopWeb() setQuery('') setShowAutocompleteResults(false) }, [setQuery]) + const onChangeText = React.useCallback( async (text: string) => { + scrollToTopWeb() + setQuery(text) if (text.length > 0) { setIsFetching(true) setShowAutocompleteResults(true) - if (searchDebounceTimeout.current) + if (searchDebounceTimeout.current) { clearTimeout(searchDebounceTimeout.current) + } searchDebounceTimeout.current = setTimeout(async () => { const results = await search({query: text, limit: 30}) @@ -498,8 +530,9 @@ export function SearchScreenMobile( } }, 300) } else { - if (searchDebounceTimeout.current) + if (searchDebounceTimeout.current) { clearTimeout(searchDebounceTimeout.current) + } setSearchResults([]) setIsFetching(false) setShowAutocompleteResults(false) @@ -507,14 +540,47 @@ export function SearchScreenMobile( }, [setQuery, search, setSearchResults], ) + + const updateSearchHistory = React.useCallback( + async (newQuery: string) => { + newQuery = newQuery.trim() + if (newQuery && !searchHistory.includes(newQuery)) { + let newHistory = [newQuery, ...searchHistory] + + if (newHistory.length > 5) { + newHistory = newHistory.slice(0, 5) + } + + setSearchHistory(newHistory) + try { + await AsyncStorage.setItem( + 'searchHistory', + JSON.stringify(newHistory), + ) + } catch (e: any) { + logger.error('Failed to save search history', e) + } + } + }, + [searchHistory, setSearchHistory], + ) + const onSubmit = React.useCallback(() => { + scrollToTopWeb() setShowAutocompleteResults(false) - }, [setShowAutocompleteResults]) + updateSearchHistory(query) + }, [query, setShowAutocompleteResults, updateSearchHistory]) const onSoftReset = React.useCallback(() => { + scrollToTopWeb() onPressCancelSearch() }, [onPressCancelSearch]) + const queryMaybeHandle = React.useMemo(() => { + const match = MATCH_HANDLE.exec(query) + return match && match[1] + }, [query]) + useFocusEffect( React.useCallback(() => { setMinimalShellMode(false) @@ -522,19 +588,47 @@ export function SearchScreenMobile( }, [onSoftReset, setMinimalShellMode]), ) + const handleHistoryItemClick = (item: React.SetStateAction<string>) => { + setQuery(item) + onSubmit() + } + + const handleRemoveHistoryItem = (itemToRemove: string) => { + const updatedHistory = searchHistory.filter(item => item !== itemToRemove) + setSearchHistory(updatedHistory) + AsyncStorage.setItem('searchHistory', JSON.stringify(updatedHistory)).catch( + e => { + logger.error('Failed to update search history', e) + }, + ) + } + return ( - <View style={{flex: 1}}> - <CenteredView style={[styles.header, pal.border]} sideBorders={isTablet}> - <Pressable - testID="viewHeaderBackOrMenuBtn" - onPress={onPressMenu} - hitSlop={HITSLOP_10} - style={styles.headerMenuBtn} - accessibilityRole="button" - accessibilityLabel={_(msg`Menu`)} - accessibilityHint="Access navigation links and settings"> - <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} /> - </Pressable> + <View style={isWeb ? null : {flex: 1}}> + <CenteredView + style={[ + styles.header, + pal.border, + pal.view, + isTabletOrDesktop && {paddingTop: 10}, + ]} + sideBorders={isTabletOrDesktop}> + {isTabletOrMobile && ( + <Pressable + testID="viewHeaderBackOrMenuBtn" + onPress={onPressMenu} + hitSlop={HITSLOP_10} + style={styles.headerMenuBtn} + accessibilityRole="button" + accessibilityLabel={_(msg`Menu`)} + accessibilityHint={_(msg`Access navigation links and settings`)}> + <FontAwesomeIcon + icon="bars" + size={18} + color={pal.colors.textLight} + /> + </Pressable> + )} <View style={[ @@ -548,7 +642,7 @@ export function SearchScreenMobile( <TextInput testID="searchTextInput" ref={textInput} - placeholder="Search" + placeholder={_(msg`Search`)} placeholderTextColor={pal.colors.textLight} selectTextOnFocus returnKeyType="search" @@ -556,7 +650,12 @@ export function SearchScreenMobile( style={[pal.text, styles.headerSearchInput]} keyboardAppearance={theme.colorScheme} onFocus={() => setInputIsFocused(true)} - onBlur={() => setInputIsFocused(false)} + onBlur={() => { + // HACK + // give 100ms to not stop click handlers in the search history + // -prf + setTimeout(() => setInputIsFocused(false), 100) + }} onChangeText={onChangeText} onSubmitEditing={onSubmit} autoFocus={false} @@ -564,6 +663,7 @@ export function SearchScreenMobile( accessibilityLabel={_(msg`Search`)} accessibilityHint="" autoCorrect={false} + autoComplete="off" autoCapitalize="none" /> {query ? ( @@ -572,7 +672,8 @@ export function SearchScreenMobile( onPress={onPressClearQuery} accessibilityRole="button" accessibilityLabel={_(msg`Clear search query`)} - accessibilityHint=""> + accessibilityHint="" + hitSlop={HITSLOP_10}> <FontAwesomeIcon icon="xmark" size={16} @@ -584,7 +685,10 @@ export function SearchScreenMobile( {query || inputIsFocused ? ( <View style={styles.headerCancelBtn}> - <Pressable onPress={onPressCancelSearch} accessibilityRole="button"> + <Pressable + onPress={onPressCancelSearch} + accessibilityRole="button" + hitSlop={HITSLOP_10}> <Text style={[pal.text]}> <Trans>Cancel</Trans> </Text> @@ -593,29 +697,83 @@ export function SearchScreenMobile( ) : undefined} </CenteredView> - {showAutocompleteResults && moderationOpts ? ( + {showAutocompleteResults ? ( <> - {isFetching ? ( + {isFetching || !moderationOpts ? ( <Loader /> ) : ( - <ScrollView style={{height: '100%'}}> - {searchResults.length ? ( - searchResults.map((item, i) => ( - <SearchResultCard - key={item.did} - profile={item} - moderation={moderateProfile(item, moderationOpts)} - style={i === 0 ? {borderTopWidth: 0} : {}} - /> - )) - ) : ( - <EmptyState message={_(msg`No results found for ${query}`)} /> - )} + <ScrollView + style={{height: '100%'}} + // @ts-ignore web only -prf + dataSet={{stableGutters: '1'}} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag"> + <SearchLinkCard + label={_(msg`Search for "${query}"`)} + onPress={isNative ? onSubmit : undefined} + to={ + isNative + ? undefined + : `/search?q=${encodeURIComponent(query)}` + } + style={{borderBottomWidth: 1}} + /> + + {queryMaybeHandle ? ( + <SearchLinkCard + label={_(msg`Go to @${queryMaybeHandle}`)} + to={`/profile/${queryMaybeHandle}`} + /> + ) : null} + + {searchResults.map(item => ( + <SearchProfileCard + key={item.did} + profile={item} + moderation={moderateProfile(item, moderationOpts)} + /> + ))} <View style={{height: 200}} /> </ScrollView> )} </> + ) : !query && inputIsFocused ? ( + <CenteredView + sideBorders={isTabletOrDesktop} + // @ts-ignore web only -prf + style={{ + height: isWeb ? '100vh' : undefined, + }}> + <View style={styles.searchHistoryContainer}> + {searchHistory.length > 0 && ( + <View style={styles.searchHistoryContent}> + <Text style={[pal.text, styles.searchHistoryTitle]}> + Recent Searches + </Text> + {searchHistory.map((historyItem, index) => ( + <View key={index} style={styles.historyItemContainer}> + <Pressable + accessibilityRole="button" + onPress={() => handleHistoryItemClick(historyItem)} + style={styles.historyItem}> + <Text style={pal.text}>{historyItem}</Text> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => handleRemoveHistoryItem(historyItem)}> + <FontAwesomeIcon + icon="xmark" + size={16} + style={pal.textLight as FontAwesomeIconStyle} + /> + </Pressable> + </View> + ))} + </View> + )} + </View> + </CenteredView> ) : ( <SearchScreenInner query={query} /> )} @@ -623,12 +781,25 @@ export function SearchScreenMobile( ) } +function scrollToTopWeb() { + if (isWeb) { + window.scrollTo(0, 0) + } +} + +const HEADER_HEIGHT = 50 + const styles = StyleSheet.create({ header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 4, + height: HEADER_HEIGHT, + // @ts-ignore web only + position: isWeb ? 'sticky' : '', + top: 0, + zIndex: 1, }, headerMenuBtn: { width: 30, @@ -658,4 +829,30 @@ const styles = StyleSheet.create({ headerCancelBtn: { paddingLeft: 10, }, + tabBarContainer: { + // @ts-ignore web only + position: isWeb ? 'sticky' : '', + top: isWeb ? HEADER_HEIGHT : 0, + zIndex: 1, + }, + searchHistoryContainer: { + width: '100%', + paddingHorizontal: 12, + }, + searchHistoryContent: { + padding: 10, + borderRadius: 8, + }, + searchHistoryTitle: { + fontWeight: 'bold', + }, + historyItem: { + paddingVertical: 8, + }, + historyItemContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 8, + }, }) diff --git a/src/view/screens/Search/index.tsx b/src/view/screens/Search/index.tsx index a65149bf7..f6c0eca26 100644 --- a/src/view/screens/Search/index.tsx +++ b/src/view/screens/Search/index.tsx @@ -1,3 +1 @@ -import {SearchScreenMobile} from '#/view/screens/Search/Search' - -export const SearchScreen = SearchScreenMobile +export {SearchScreen} from '#/view/screens/Search/Search' diff --git a/src/view/screens/Search/index.web.tsx b/src/view/screens/Search/index.web.tsx deleted file mode 100644 index 8e039e3cd..000000000 --- a/src/view/screens/Search/index.web.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import {SearchScreenDesktop} from '#/view/screens/Search/Search' - -export const SearchScreen = SearchScreenDesktop diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index d48112dae..3b50c5449 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -19,7 +19,6 @@ import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import * as AppInfo from 'lib/app-info' import {s, colors} from 'lib/styles' import {ScrollView} from '../com/util/Views' -import {ViewHeader} from '../com/util/ViewHeader' import {Link, TextLink} from '../com/util/Link' import {Text} from '../com/util/text/Text' import * as Toast from '../com/util/Toast' @@ -36,6 +35,7 @@ import {HandIcon, HashtagIcon} from 'lib/icons' import Clipboard from '@react-native-clipboard/clipboard' import {makeProfileLink} from 'lib/routes/links' import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' +import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' import {useModalControls} from '#/state/modals' import { @@ -70,9 +70,15 @@ import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' +import { + useInAppBrowser, + useSetInAppBrowser, +} from '#/state/preferences/in-app-browser' +import {isNative} from '#/platform/detection' function SettingsAccountCard({account}: {account: SessionAccount}) { const pal = usePalette('default') + const {_} = useLingui() const {isSwitchingAccounts, currentAccount} = useSession() const {logout} = useSessionApi() const {data: profile} = useProfileQuery({did: account.did}) @@ -98,10 +104,10 @@ function SettingsAccountCard({account}: {account: SessionAccount}) { testID="signOutBtn" onPress={logout} accessibilityRole="button" - accessibilityLabel="Sign out" + accessibilityLabel={_(msg`Sign out`)} accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}> <Text type="lg" style={pal.link}> - Sign out + <Trans>Sign out</Trans> </Text> </TouchableOpacity> ) : ( @@ -116,7 +122,7 @@ function SettingsAccountCard({account}: {account: SessionAccount}) { did: currentAccount?.did, handle: currentAccount?.handle, })} - title="Your profile" + title={_(msg`Your profile`)} noFeedback> {contents} </Link> @@ -128,8 +134,8 @@ function SettingsAccountCard({account}: {account: SessionAccount}) { isSwitchingAccounts ? undefined : () => onPressSwitchAccount(account) } accessibilityRole="button" - accessibilityLabel={`Switch to ${account.handle}`} - accessibilityHint="Switches the account you are logged in to"> + accessibilityLabel={_(msg`Switch to ${account.handle}`)} + accessibilityHint={_(msg`Switches the account you are logged in to`)}> {contents} </TouchableOpacity> ) @@ -145,6 +151,8 @@ export function SettingsScreen({}: Props) { const setMinimalShellMode = useSetMinimalShellMode() const requireAltTextEnabled = useRequireAltTextEnabled() const setRequireAltTextEnabled = useSetRequireAltTextEnabled() + const inAppBrowserPref = useInAppBrowser() + const setUseInAppBrowser = useSetInAppBrowser() const onboardingDispatch = useOnboardingDispatch() const navigation = useNavigation<NavigationProp>() const {isMobile} = useWebMediaQueries() @@ -225,15 +233,15 @@ export function SettingsScreen({}: Props) { const onPressResetOnboarding = React.useCallback(async () => { onboardingDispatch({type: 'start'}) - Toast.show('Onboarding reset') - }, [onboardingDispatch]) + Toast.show(_(msg`Onboarding reset`)) + }, [onboardingDispatch, _]) const onPressBuildInfo = React.useCallback(() => { Clipboard.setString( `Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`, ) - Toast.show('Copied build version to clipboard') - }, []) + Toast.show(_(msg`Copied build version to clipboard`)) + }, [_]) const openHomeFeedPreferences = React.useCallback(() => { navigation.navigate('PreferencesHomeFeed') @@ -265,20 +273,34 @@ export function SettingsScreen({}: Props) { const clearAllStorage = React.useCallback(async () => { await clearStorage() - Toast.show(`Storage cleared, you need to restart the app now.`) - }, []) + Toast.show(_(msg`Storage cleared, you need to restart the app now.`)) + }, [_]) const clearAllLegacyStorage = React.useCallback(async () => { await clearLegacyStorage() - Toast.show(`Legacy storage cleared, you need to restart the app now.`) - }, []) + Toast.show(_(msg`Legacy storage cleared, you need to restart the app now.`)) + }, [_]) return ( - <View style={[s.hContentRegion]} testID="settingsScreen"> - <ViewHeader title={_(msg`Settings`)} /> + <View style={s.hContentRegion} testID="settingsScreen"> + <SimpleViewHeader + showBackButton={isMobile} + style={[ + pal.border, + {borderBottomWidth: 1}, + !isMobile && {borderLeftWidth: 1, borderRightWidth: 1}, + ]}> + <View style={{flex: 1}}> + <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> + <Trans>Settings</Trans> + </Text> + </View> + </SimpleViewHeader> <ScrollView style={[s.hContentRegion]} contentContainerStyle={isMobile && pal.viewLight} - scrollIndicatorInsets={{right: 1}}> + scrollIndicatorInsets={{right: 1}} + // @ts-ignore web only -prf + dataSet={{'stable-gutters': 1}}> <View style={styles.spacer20} /> {currentAccount ? ( <> @@ -298,12 +320,18 @@ export function SettingsScreen({}: Props) { /> </> )} - <Text type="lg" style={pal.text}> - {currentAccount.email || '(no email)'}{' '} + <Text + type="lg" + numberOfLines={1} + style={[ + pal.text, + {overflow: 'hidden', marginRight: 4, flex: 1}, + ]}> + {currentAccount.email || '(no email)'} </Text> <Link onPress={() => openModal({name: 'change-email'})}> <Text type="lg" style={pal.link}> - <Trans>Change</Trans> + <Trans context="action">Change</Trans> </Text> </Link> </View> @@ -353,7 +381,7 @@ export function SettingsScreen({}: Props) { onPress={isSwitchingAccounts ? undefined : onPressAddAccount} accessibilityRole="button" accessibilityLabel={_(msg`Add account`)} - accessibilityHint="Create a new Bluesky account"> + accessibilityHint={_(msg`Create a new Bluesky account`)}> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="plus" @@ -381,7 +409,7 @@ export function SettingsScreen({}: Props) { onPress={isSwitchingAccounts ? undefined : onPressInviteCodes} accessibilityRole="button" accessibilityLabel={_(msg`Invite`)} - accessibilityHint="Opens invite code list" + accessibilityHint={_(msg`Opens invite code list`)} disabled={invites?.disabled}> <View style={[ @@ -419,7 +447,7 @@ export function SettingsScreen({}: Props) { <View style={[pal.view, styles.toggleCard]}> <ToggleButton type="default-light" - label="Require alt text before posting" + label={_(msg`Require alt text before posting`)} labelType="lg" isSelected={requireAltTextEnabled} onPress={() => setRequireAltTextEnabled(!requireAltTextEnabled)} @@ -435,23 +463,23 @@ export function SettingsScreen({}: Props) { <View style={[styles.linkCard, pal.view, styles.selectableBtns]}> <SelectableBtn selected={colorMode === 'system'} - label="System" + label={_(msg`System`)} left onSelect={() => setColorMode('system')} - accessibilityHint="Set color theme to system setting" + accessibilityHint={_(msg`Set color theme to system setting`)} /> <SelectableBtn selected={colorMode === 'light'} - label="Light" + label={_(msg`Light`)} onSelect={() => setColorMode('light')} - accessibilityHint="Set color theme to light" + accessibilityHint={_(msg`Set color theme to light`)} /> <SelectableBtn selected={colorMode === 'dark'} - label="Dark" + label={_(msg`Dark`)} right onSelect={() => setColorMode('dark')} - accessibilityHint="Set color theme to dark" + accessibilityHint={_(msg`Set color theme to dark`)} /> </View> </View> @@ -529,8 +557,8 @@ export function SettingsScreen({}: Props) { ]} onPress={isSwitchingAccounts ? undefined : onPressLanguageSettings} accessibilityRole="button" - accessibilityHint="Language settings" - accessibilityLabel={_(msg`Opens configurable language settings`)}> + accessibilityLabel={_(msg`Language settings`)} + accessibilityHint={_(msg`Opens configurable language settings`)}> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="language" @@ -554,8 +582,8 @@ export function SettingsScreen({}: Props) { : () => navigation.navigate('Moderation') } accessibilityRole="button" - accessibilityHint="" - accessibilityLabel={_(msg`Opens moderation settings`)}> + accessibilityLabel={_(msg`Moderation settings`)} + accessibilityHint={_(msg`Opens moderation settings`)}> <View style={[styles.iconContainer, pal.btn]}> <HandIcon style={pal.text} size={18} strokeWidth={6} /> </View> @@ -563,6 +591,39 @@ export function SettingsScreen({}: Props) { <Trans>Moderation</Trans> </Text> </TouchableOpacity> + + <View style={styles.spacer20} /> + + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Privacy</Trans> + </Text> + + <TouchableOpacity + testID="externalEmbedsBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={ + isSwitchingAccounts + ? undefined + : () => navigation.navigate('PreferencesExternalEmbeds') + } + accessibilityRole="button" + accessibilityLabel={_(msg`External media settings`)} + accessibilityHint={_(msg`Opens external embeds settings`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon={['far', 'circle-play']} + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>External Media Preferences</Trans> + </Text> + </TouchableOpacity> + <View style={styles.spacer20} /> <Text type="xl-bold" style={[pal.text, styles.heading]}> @@ -577,8 +638,8 @@ export function SettingsScreen({}: Props) { ]} onPress={onPressAppPasswords} accessibilityRole="button" - accessibilityHint="Open app password settings" - accessibilityLabel={_(msg`Opens the app password settings page`)}> + accessibilityLabel={_(msg`App password settings`)} + accessibilityHint={_(msg`Opens the app password settings page`)}> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="lock" @@ -599,7 +660,7 @@ export function SettingsScreen({}: Props) { onPress={isSwitchingAccounts ? undefined : onPressChangeHandle} accessibilityRole="button" accessibilityLabel={_(msg`Change handle`)} - accessibilityHint="Choose a new Bluesky username or create"> + accessibilityHint={_(msg`Choose a new Bluesky username or create`)}> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="at" @@ -610,6 +671,17 @@ export function SettingsScreen({}: Props) { <Trans>Change handle</Trans> </Text> </TouchableOpacity> + {isNative && ( + <View style={[pal.view, styles.toggleCard]}> + <ToggleButton + type="default-light" + label={_(msg`Open links with in-app browser`)} + labelType="lg" + isSelected={inAppBrowserPref ?? false} + onPress={() => setUseInAppBrowser(!inAppBrowserPref)} + /> + </View> + )} <View style={styles.spacer20} /> <Text type="xl-bold" style={[pal.text, styles.heading]}> <Trans>Danger Zone</Trans> @@ -620,7 +692,9 @@ export function SettingsScreen({}: Props) { accessible={true} accessibilityRole="button" accessibilityLabel={_(msg`Delete account`)} - accessibilityHint="Opens modal for account deletion confirmation. Requires email code."> + accessibilityHint={_( + msg`Opens modal for account deletion confirmation. Requires email code.`, + )}> <View style={[styles.iconContainer, dangerBg]}> <FontAwesomeIcon icon={['far', 'trash-can']} @@ -660,8 +734,8 @@ export function SettingsScreen({}: Props) { style={[pal.view, styles.linkCardNoIcon]} onPress={onPressStorybook} accessibilityRole="button" - accessibilityHint="Open storybook page" - accessibilityLabel={_(msg`Opens the storybook page`)}> + accessibilityLabel={_(msg`Open storybook page`)} + accessibilityHint={_(msg`Opens the storybook page`)}> <Text type="lg" style={pal.text}> <Trans>Storybook</Trans> </Text> @@ -670,8 +744,8 @@ export function SettingsScreen({}: Props) { style={[pal.view, styles.linkCardNoIcon]} onPress={onPressResetPreferences} accessibilityRole="button" - accessibilityHint="Reset preferences" - accessibilityLabel={_(msg`Resets the preferences state`)}> + accessibilityLabel={_(msg`Reset preferences`)} + accessibilityHint={_(msg`Resets the preferences state`)}> <Text type="lg" style={pal.text}> <Trans>Reset preferences state</Trans> </Text> @@ -680,8 +754,8 @@ export function SettingsScreen({}: Props) { style={[pal.view, styles.linkCardNoIcon]} onPress={onPressResetOnboarding} accessibilityRole="button" - accessibilityHint="Reset onboarding" - accessibilityLabel={_(msg`Resets the onboarding state`)}> + accessibilityLabel={_(msg`Reset onboarding`)} + accessibilityHint={_(msg`Resets the onboarding state`)}> <Text type="lg" style={pal.text}> <Trans>Reset onboarding state</Trans> </Text> @@ -690,8 +764,8 @@ export function SettingsScreen({}: Props) { style={[pal.view, styles.linkCardNoIcon]} onPress={clearAllLegacyStorage} accessibilityRole="button" - accessibilityHint="Clear all legacy storage data" - accessibilityLabel={_(msg`Clear all legacy storage data`)}> + accessibilityLabel={_(msg`Clear all legacy storage data`)} + accessibilityHint={_(msg`Clear all legacy storage data`)}> <Text type="lg" style={pal.text}> <Trans> Clear all legacy storage data (restart after this) @@ -702,8 +776,8 @@ export function SettingsScreen({}: Props) { style={[pal.view, styles.linkCardNoIcon]} onPress={clearAllStorage} accessibilityRole="button" - accessibilityHint="Clear all storage data" - accessibilityLabel={_(msg`Clear all storage data`)}> + accessibilityLabel={_(msg`Clear all storage data`)} + accessibilityHint={_(msg`Clear all storage data`)}> <Text type="lg" style={pal.text}> <Trans>Clear all storage data (restart after this)</Trans> </Text> diff --git a/src/view/screens/Storybook/Breakpoints.tsx b/src/view/screens/Storybook/Breakpoints.tsx new file mode 100644 index 000000000..1b846d517 --- /dev/null +++ b/src/view/screens/Storybook/Breakpoints.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a, useTheme, useBreakpoints} from '#/alf' +import {Text, H3} from '#/components/Typography' + +export function Breakpoints() { + const t = useTheme() + const breakpoints = useBreakpoints() + + return ( + <View> + <H3 style={[a.pb_md]}>Breakpoint Debugger</H3> + <Text style={[a.pb_md]}> + Current breakpoint: {!breakpoints.gtMobile && <Text>mobile</Text>} + {breakpoints.gtMobile && !breakpoints.gtTablet && <Text>tablet</Text>} + {breakpoints.gtTablet && <Text>desktop</Text>} + </Text> + <Text + style={[a.p_md, t.atoms.bg_contrast_100, {fontFamily: 'monospace'}]}> + {JSON.stringify(breakpoints, null, 2)} + </Text> + </View> + ) +} diff --git a/src/view/screens/Storybook/Buttons.tsx b/src/view/screens/Storybook/Buttons.tsx new file mode 100644 index 000000000..fbdc84eb4 --- /dev/null +++ b/src/view/screens/Storybook/Buttons.tsx @@ -0,0 +1,124 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import { + Button, + ButtonVariant, + ButtonColor, + ButtonIcon, + ButtonText, +} from '#/components/Button' +import {H1} from '#/components/Typography' +import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight' +import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' + +export function Buttons() { + return ( + <View style={[a.gap_md]}> + <H1>Buttons</H1> + + <View style={[a.flex_row, a.flex_wrap, a.gap_md, a.align_start]}> + {['primary', 'secondary', 'negative'].map(color => ( + <View key={color} style={[a.gap_md, a.align_start]}> + {['solid', 'outline', 'ghost'].map(variant => ( + <React.Fragment key={variant}> + <Button + variant={variant as ButtonVariant} + color={color as ButtonColor} + size="large" + label="Click here"> + Button + </Button> + <Button + disabled + variant={variant as ButtonVariant} + color={color as ButtonColor} + size="large" + label="Click here"> + Button + </Button> + </React.Fragment> + ))} + </View> + ))} + + <View style={[a.flex_row, a.gap_md, a.align_start]}> + <View style={[a.gap_md, a.align_start]}> + {['gradient_sky', 'gradient_midnight', 'gradient_sunrise'].map( + name => ( + <React.Fragment key={name}> + <Button + variant="gradient" + color={name as ButtonColor} + size="large" + label="Click here"> + Button + </Button> + <Button + disabled + variant="gradient" + color={name as ButtonColor} + size="large" + label="Click here"> + Button + </Button> + </React.Fragment> + ), + )} + </View> + <View style={[a.gap_md, a.align_start]}> + {['gradient_sunset', 'gradient_nordic', 'gradient_bonfire'].map( + name => ( + <React.Fragment key={name}> + <Button + variant="gradient" + color={name as ButtonColor} + size="large" + label="Click here"> + Button + </Button> + <Button + disabled + variant="gradient" + color={name as ButtonColor} + size="large" + label="Click here"> + Button + </Button> + </React.Fragment> + ), + )} + </View> + </View> + + <Button + variant="gradient" + color="gradient_sky" + size="large" + label="Link out"> + <ButtonText>Link out</ButtonText> + <ButtonIcon icon={ArrowTopRight} /> + </Button> + + <Button + variant="gradient" + color="gradient_sky" + size="small" + label="Link out"> + <ButtonText>Link out</ButtonText> + <ButtonIcon icon={ArrowTopRight} /> + </Button> + + <Button + variant="gradient" + color="gradient_sky" + size="small" + label="Link out"> + <ButtonIcon icon={Globe} /> + <ButtonText>See the world</ButtonText> + </Button> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx new file mode 100644 index 000000000..db568c6bd --- /dev/null +++ b/src/view/screens/Storybook/Dialogs.tsx @@ -0,0 +1,90 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import {Button} from '#/components/Button' +import {H3, P} from '#/components/Typography' +import * as Dialog from '#/components/Dialog' +import * as Prompt from '#/components/Prompt' +import {useDialogStateControlContext} from '#/state/dialogs' + +export function Dialogs() { + const control = Dialog.useDialogControl() + const prompt = Prompt.usePromptControl() + const {closeAllDialogs} = useDialogStateControlContext() + + return ( + <View style={[a.gap_md]}> + <Button + variant="outline" + color="secondary" + size="small" + onPress={() => { + control.open() + prompt.open() + }} + label="Open basic dialog"> + Open basic dialog + </Button> + + <Button + variant="solid" + color="primary" + size="small" + onPress={() => prompt.open()} + label="Open prompt"> + Open prompt + </Button> + + <Prompt.Outer control={prompt}> + <Prompt.Title>This is a prompt</Prompt.Title> + <Prompt.Description> + This is a generic prompt component. It accepts a title and a + description, as well as two actions. + </Prompt.Description> + <Prompt.Actions> + <Prompt.Cancel>Cancel</Prompt.Cancel> + <Prompt.Action>Confirm</Prompt.Action> + </Prompt.Actions> + </Prompt.Outer> + + <Dialog.Outer + control={control} + nativeOptions={{sheet: {snapPoints: ['90%']}}}> + <Dialog.Handle /> + + <Dialog.ScrollableInner + accessibilityDescribedBy="dialog-description" + accessibilityLabelledBy="dialog-title"> + <View style={[a.relative, a.gap_md, a.w_full]}> + <H3 nativeID="dialog-title">Dialog</H3> + <P nativeID="dialog-description"> + A scrollable dialog with an input within it. + </P> + <Dialog.Input value="" onChangeText={() => {}} label="Type here" /> + + <Button + variant="outline" + color="secondary" + size="small" + onPress={closeAllDialogs} + label="Close all dialogs"> + Close all dialogs + </Button> + <View style={{height: 1000}} /> + <View style={[a.flex_row, a.justify_end]}> + <Button + variant="outline" + color="primary" + size="small" + onPress={() => control.close()} + label="Open basic dialog"> + Close basic dialog + </Button> + </View> + </View> + </Dialog.ScrollableInner> + </Dialog.Outer> + </View> + ) +} diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx new file mode 100644 index 000000000..9396cca67 --- /dev/null +++ b/src/view/screens/Storybook/Forms.tsx @@ -0,0 +1,215 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import {H1, H3} from '#/components/Typography' +import * as TextField from '#/components/forms/TextField' +import {DateField, Label} from '#/components/forms/DateField' +import * as Toggle from '#/components/forms/Toggle' +import * as ToggleButton from '#/components/forms/ToggleButton' +import {Button} from '#/components/Button' +import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' + +export function Forms() { + const [toggleGroupAValues, setToggleGroupAValues] = React.useState(['a']) + const [toggleGroupBValues, setToggleGroupBValues] = React.useState(['a', 'b']) + const [toggleGroupCValues, setToggleGroupCValues] = React.useState(['a', 'b']) + const [toggleGroupDValues, setToggleGroupDValues] = React.useState(['warn']) + + const [value, setValue] = React.useState('') + const [date, setDate] = React.useState('2001-01-01') + + return ( + <View style={[a.gap_4xl, a.align_start]}> + <H1>Forms</H1> + + <View style={[a.gap_md, a.align_start, a.w_full]}> + <H3>InputText</H3> + + <TextField.Input + value={value} + onChangeText={setValue} + label="Text field" + /> + + <TextField.Root> + <TextField.Icon icon={Globe} /> + <TextField.Input + value={value} + onChangeText={setValue} + label="Text field" + /> + </TextField.Root> + + <View style={[a.w_full]}> + <TextField.Label>Text field</TextField.Label> + <TextField.Root> + <TextField.Icon icon={Globe} /> + <TextField.Input + value={value} + onChangeText={setValue} + label="Text field" + /> + <TextField.Suffix label="@gmail.com">@gmail.com</TextField.Suffix> + </TextField.Root> + </View> + + <View style={[a.w_full]}> + <TextField.Label>Textarea</TextField.Label> + <TextField.Input + multiline + numberOfLines={4} + value={value} + onChangeText={setValue} + label="Text field" + /> + </View> + + <H3>DateField</H3> + + <View style={[a.w_full]}> + <Label>Date</Label> + <DateField + testID="date" + value={date} + onChangeDate={date => { + console.log(date) + setDate(date) + }} + label="Input" + /> + </View> + </View> + + <View style={[a.gap_md, a.align_start, a.w_full]}> + <H3>Toggles</H3> + + <Toggle.Item name="a" label="Click me"> + <Toggle.Checkbox /> + <Toggle.Label>Uncontrolled toggle</Toggle.Label> + </Toggle.Item> + + <Toggle.Group + label="Toggle" + type="checkbox" + maxSelections={2} + values={toggleGroupAValues} + onChange={setToggleGroupAValues}> + <View style={[a.gap_md]}> + <Toggle.Item name="a" label="Click me"> + <Toggle.Switch /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="b" label="Click me"> + <Toggle.Switch /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="c" label="Click me"> + <Toggle.Switch /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="d" disabled label="Click me"> + <Toggle.Switch /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="e" isInvalid label="Click me"> + <Toggle.Switch /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + + <Toggle.Group + label="Toggle" + type="checkbox" + maxSelections={2} + values={toggleGroupBValues} + onChange={setToggleGroupBValues}> + <View style={[a.gap_md]}> + <Toggle.Item name="a" label="Click me"> + <Toggle.Checkbox /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="b" label="Click me"> + <Toggle.Checkbox /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="c" label="Click me"> + <Toggle.Checkbox /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="d" disabled label="Click me"> + <Toggle.Checkbox /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="e" isInvalid label="Click me"> + <Toggle.Checkbox /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + + <Toggle.Group + label="Toggle" + type="radio" + values={toggleGroupCValues} + onChange={setToggleGroupCValues}> + <View style={[a.gap_md]}> + <Toggle.Item name="a" label="Click me"> + <Toggle.Radio /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="b" label="Click me"> + <Toggle.Radio /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="c" label="Click me"> + <Toggle.Radio /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="d" disabled label="Click me"> + <Toggle.Radio /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="e" isInvalid label="Click me"> + <Toggle.Radio /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + </View> + + <Button + variant="gradient" + color="gradient_nordic" + size="small" + label="Reset all toggles" + onPress={() => { + setToggleGroupAValues(['a']) + setToggleGroupBValues(['a', 'b']) + setToggleGroupCValues(['a']) + }}> + Reset all toggles + </Button> + + <View style={[a.gap_md, a.align_start, a.w_full]}> + <H3>ToggleButton</H3> + + <ToggleButton.Group + label="Preferences" + values={toggleGroupDValues} + onChange={setToggleGroupDValues}> + <ToggleButton.Button name="hide" label="Hide"> + Hide + </ToggleButton.Button> + <ToggleButton.Button name="warn" label="Warn"> + Warn + </ToggleButton.Button> + <ToggleButton.Button name="show" label="Show"> + Show + </ToggleButton.Button> + </ToggleButton.Group> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Icons.tsx b/src/view/screens/Storybook/Icons.tsx new file mode 100644 index 000000000..73466e077 --- /dev/null +++ b/src/view/screens/Storybook/Icons.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {H1} from '#/components/Typography' +import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' +import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight' +import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' + +export function Icons() { + const t = useTheme() + return ( + <View style={[a.gap_md]}> + <H1>Icons</H1> + + <View style={[a.flex_row, a.gap_xl]}> + <Globe size="xs" fill={t.atoms.text.color} /> + <Globe size="sm" fill={t.atoms.text.color} /> + <Globe size="md" fill={t.atoms.text.color} /> + <Globe size="lg" fill={t.atoms.text.color} /> + <Globe size="xl" fill={t.atoms.text.color} /> + </View> + + <View style={[a.flex_row, a.gap_xl]}> + <ArrowTopRight size="xs" fill={t.atoms.text.color} /> + <ArrowTopRight size="sm" fill={t.atoms.text.color} /> + <ArrowTopRight size="md" fill={t.atoms.text.color} /> + <ArrowTopRight size="lg" fill={t.atoms.text.color} /> + <ArrowTopRight size="xl" fill={t.atoms.text.color} /> + </View> + + <View style={[a.flex_row, a.gap_xl]}> + <CalendarDays size="xs" fill={t.atoms.text.color} /> + <CalendarDays size="sm" fill={t.atoms.text.color} /> + <CalendarDays size="md" fill={t.atoms.text.color} /> + <CalendarDays size="lg" fill={t.atoms.text.color} /> + <CalendarDays size="xl" fill={t.atoms.text.color} /> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Links.tsx b/src/view/screens/Storybook/Links.tsx new file mode 100644 index 000000000..c3b1c0e0f --- /dev/null +++ b/src/view/screens/Storybook/Links.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import {ButtonText} from '#/components/Button' +import {Link} from '#/components/Link' +import {H1, H3} from '#/components/Typography' + +export function Links() { + return ( + <View style={[a.gap_md, a.align_start]}> + <H1>Links</H1> + + <View style={[a.gap_md, a.align_start]}> + <Link + to="https://blueskyweb.xyz" + warnOnMismatchingTextChild + style={[a.text_md]}> + External + </Link> + <Link to="https://blueskyweb.xyz" style={[a.text_md]}> + <H3>External with custom children</H3> + </Link> + <Link + to="https://blueskyweb.xyz" + warnOnMismatchingTextChild + style={[a.text_lg]}> + https://blueskyweb.xyz + </Link> + <Link + to="https://bsky.app/profile/bsky.app" + warnOnMismatchingTextChild + style={[a.text_md]}> + Internal + </Link> + + <Link + variant="solid" + color="primary" + size="large" + label="View @bsky.app's profile" + to="https://bsky.app/profile/bsky.app"> + <ButtonText>Link as a button</ButtonText> + </Link> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Palette.tsx b/src/view/screens/Storybook/Palette.tsx new file mode 100644 index 000000000..b521fe860 --- /dev/null +++ b/src/view/screens/Storybook/Palette.tsx @@ -0,0 +1,336 @@ +import React from 'react' +import {View} from 'react-native' + +import * as tokens from '#/alf/tokens' +import {atoms as a} from '#/alf' + +export function Palette() { + return ( + <View style={[a.gap_md]}> + <View style={[a.flex_row, a.gap_md]}> + <View + style={[a.flex_1, {height: 60, backgroundColor: tokens.color.gray_0}]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_25}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_50}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_100}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_200}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_300}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_400}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_500}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_600}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_700}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_800}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_900}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_950}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_975}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_1000}, + ]} + /> + </View> + + <View style={[a.flex_row, a.gap_md]}> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_25}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_50}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_100}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_200}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_300}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_400}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_500}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_600}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_700}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_800}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_900}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_950}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_975}, + ]} + /> + </View> + <View style={[a.flex_row, a.gap_md]}> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_25}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_50}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_100}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_200}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_300}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_400}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_500}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_600}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_700}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_800}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_900}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_950}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_975}, + ]} + /> + </View> + <View style={[a.flex_row, a.gap_md]}> + <View + style={[a.flex_1, {height: 60, backgroundColor: tokens.color.red_25}]} + /> + <View + style={[a.flex_1, {height: 60, backgroundColor: tokens.color.red_50}]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_100}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_200}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_300}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_400}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_500}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_600}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_700}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_800}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_900}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_950}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_975}, + ]} + /> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Shadows.tsx b/src/view/screens/Storybook/Shadows.tsx new file mode 100644 index 000000000..f92112395 --- /dev/null +++ b/src/view/screens/Storybook/Shadows.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {H1, Text} from '#/components/Typography' + +export function Shadows() { + const t = useTheme() + + return ( + <View style={[a.gap_md]}> + <H1>Shadows</H1> + + <View style={[a.flex_row, a.gap_5xl]}> + <View + style={[ + a.flex_1, + a.justify_center, + a.px_lg, + a.py_2xl, + t.atoms.bg, + t.atoms.shadow_sm, + ]}> + <Text>shadow_sm</Text> + </View> + + <View + style={[ + a.flex_1, + a.justify_center, + a.px_lg, + a.py_2xl, + t.atoms.bg, + t.atoms.shadow_md, + ]}> + <Text>shadow_md</Text> + </View> + + <View + style={[ + a.flex_1, + a.justify_center, + a.px_lg, + a.py_2xl, + t.atoms.bg, + t.atoms.shadow_lg, + ]}> + <Text>shadow_lg</Text> + </View> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Spacing.tsx b/src/view/screens/Storybook/Spacing.tsx new file mode 100644 index 000000000..d7faf93a8 --- /dev/null +++ b/src/view/screens/Storybook/Spacing.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {Text, H1} from '#/components/Typography' + +export function Spacing() { + const t = useTheme() + return ( + <View style={[a.gap_md]}> + <H1>Spacing</H1> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>2xs (2px)</Text> + <View style={[a.flex_1, a.pt_2xs, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>xs (4px)</Text> + <View style={[a.flex_1, a.pt_xs, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>sm (8px)</Text> + <View style={[a.flex_1, a.pt_sm, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>md (12px)</Text> + <View style={[a.flex_1, a.pt_md, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>lg (16px)</Text> + <View style={[a.flex_1, a.pt_lg, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>xl (20px)</Text> + <View style={[a.flex_1, a.pt_xl, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>2xl (24px)</Text> + <View style={[a.flex_1, a.pt_2xl, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>3xl (28px)</Text> + <View style={[a.flex_1, a.pt_3xl, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>4xl (32px)</Text> + <View style={[a.flex_1, a.pt_4xl, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>5xl (40px)</Text> + <View style={[a.flex_1, a.pt_5xl, t.atoms.bg_contrast_300]} /> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Theming.tsx b/src/view/screens/Storybook/Theming.tsx new file mode 100644 index 000000000..a05443473 --- /dev/null +++ b/src/view/screens/Storybook/Theming.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import {Palette} from './Palette' + +export function Theming() { + const t = useTheme() + + return ( + <View style={[t.atoms.bg, a.gap_lg, a.p_xl]}> + <Palette /> + + <Text style={[a.font_bold, a.pt_xl, a.px_md]}>theme.atoms.text</Text> + + <View style={[a.flex_1, t.atoms.border, a.border_t]} /> + <Text style={[a.font_bold, t.atoms.text_contrast_600, a.px_md]}> + theme.atoms.text_contrast_600 + </Text> + + <View style={[a.flex_1, t.atoms.border, a.border_t]} /> + <Text style={[a.font_bold, t.atoms.text_contrast_500, a.px_md]}> + theme.atoms.text_contrast_500 + </Text> + + <View style={[a.flex_1, t.atoms.border, a.border_t]} /> + <Text style={[a.font_bold, t.atoms.text_contrast_400, a.px_md]}> + theme.atoms.text_contrast_400 + </Text> + + <View style={[a.flex_1, t.atoms.border_contrast, a.border_t]} /> + + <View style={[a.w_full, a.gap_md]}> + <View style={[t.atoms.bg, a.justify_center, a.p_md]}> + <Text>theme.atoms.bg</Text> + </View> + <View style={[t.atoms.bg_contrast_25, a.justify_center, a.p_md]}> + <Text>theme.atoms.bg_contrast_25</Text> + </View> + <View style={[t.atoms.bg_contrast_50, a.justify_center, a.p_md]}> + <Text>theme.atoms.bg_contrast_50</Text> + </View> + <View style={[t.atoms.bg_contrast_100, a.justify_center, a.p_md]}> + <Text>theme.atoms.bg_contrast_100</Text> + </View> + <View style={[t.atoms.bg_contrast_200, a.justify_center, a.p_md]}> + <Text>theme.atoms.bg_contrast_200</Text> + </View> + <View style={[t.atoms.bg_contrast_300, a.justify_center, a.p_md]}> + <Text>theme.atoms.bg_contrast_300</Text> + </View> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Typography.tsx b/src/view/screens/Storybook/Typography.tsx new file mode 100644 index 000000000..2e1f04a66 --- /dev/null +++ b/src/view/screens/Storybook/Typography.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import {Text, H1, H2, H3, H4, H5, H6, P} from '#/components/Typography' + +export function Typography() { + return ( + <View style={[a.gap_md]}> + <H1>H1 Heading</H1> + <H2>H2 Heading</H2> + <H3>H3 Heading</H3> + <H4>H4 Heading</H4> + <H5>H5 Heading</H5> + <H6>H6 Heading</H6> + <P>P Paragraph</P> + + <Text style={[a.text_5xl]}>atoms.text_5xl</Text> + <Text style={[a.text_4xl]}>atoms.text_4xl</Text> + <Text style={[a.text_3xl]}>atoms.text_3xl</Text> + <Text style={[a.text_2xl]}>atoms.text_2xl</Text> + <Text style={[a.text_xl]}>atoms.text_xl</Text> + <Text style={[a.text_lg]}>atoms.text_lg</Text> + <Text style={[a.text_md]}>atoms.text_md</Text> + <Text style={[a.text_sm]}>atoms.text_sm</Text> + <Text style={[a.text_xs]}>atoms.text_xs</Text> + <Text style={[a.text_2xs]}>atoms.text_2xs</Text> + </View> + ) +} diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx new file mode 100644 index 000000000..d8898f20e --- /dev/null +++ b/src/view/screens/Storybook/index.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import {View} from 'react-native' +import {CenteredView, ScrollView} from '#/view/com/util/Views' + +import {atoms as a, useTheme, ThemeProvider} from '#/alf' +import {useSetColorMode} from '#/state/shell' +import {Button} from '#/components/Button' + +import {Theming} from './Theming' +import {Typography} from './Typography' +import {Spacing} from './Spacing' +import {Buttons} from './Buttons' +import {Links} from './Links' +import {Forms} from './Forms' +import {Dialogs} from './Dialogs' +import {Breakpoints} from './Breakpoints' +import {Shadows} from './Shadows' +import {Icons} from './Icons' + +export function Storybook() { + const t = useTheme() + const setColorMode = useSetColorMode() + + return ( + <ScrollView> + <CenteredView style={[t.atoms.bg]}> + <View style={[a.p_xl, a.gap_5xl, {paddingBottom: 200}]}> + <View style={[a.flex_row, a.align_start, a.gap_md]}> + <Button + variant="outline" + color="primary" + size="small" + label='Set theme to "system"' + onPress={() => setColorMode('system')}> + System + </Button> + <Button + variant="solid" + color="secondary" + size="small" + label='Set theme to "system"' + onPress={() => setColorMode('light')}> + Light + </Button> + <Button + variant="solid" + color="secondary" + size="small" + label='Set theme to "system"' + onPress={() => setColorMode('dark')}> + Dark + </Button> + </View> + + <ThemeProvider theme="light"> + <Theming /> + </ThemeProvider> + <ThemeProvider theme="dim"> + <Theming /> + </ThemeProvider> + <ThemeProvider theme="dark"> + <Theming /> + </ThemeProvider> + + <Typography /> + <Spacing /> + <Shadows /> + <Buttons /> + <Icons /> + <Links /> + <Forms /> + <Dialogs /> + <Breakpoints /> + </View> + </CenteredView> + </ScrollView> + ) +} diff --git a/src/view/screens/Support.tsx b/src/view/screens/Support.tsx index 6856f6759..9e7d36ec7 100644 --- a/src/view/screens/Support.tsx +++ b/src/view/screens/Support.tsx @@ -34,10 +34,10 @@ export const SupportScreen = (_props: Props) => { </Text> <Text style={[pal.text, s.p20]}> <Trans> - The support form has been moved. If you need help, please + The support form has been moved. If you need help, please{' '} <TextLink href={HELP_DESK_URL} - text=" click here" + text={_(msg`click here`)} style={pal.link} />{' '} or visit {HELP_DESK_URL} to get in touch with us. diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index 73f9f540e..99e659d62 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -5,6 +5,11 @@ import {ComposePost} from '../com/composer/Composer' import {useComposerState} from 'state/shell/composer' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' +import { + EmojiPicker, + EmojiPickerState, +} from 'view/com/composer/text-input/web/EmojiPicker.web.tsx' const BOTTOM_BAR_HEIGHT = 61 @@ -12,11 +17,33 @@ export function Composer({}: {winHeight: number}) { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() const state = useComposerState() + const isActive = !!state + useWebBodyScrollLock(isActive) + + const [pickerState, setPickerState] = React.useState<EmojiPickerState>({ + isOpen: false, + pos: {top: 0, left: 0, right: 0, bottom: 0}, + }) + + const onOpenPicker = React.useCallback((pos: DOMRect | undefined) => { + if (!pos) return + setPickerState({ + isOpen: true, + pos, + }) + }, []) + + const onClosePicker = React.useCallback(() => { + setPickerState(prev => ({ + ...prev, + isOpen: false, + })) + }, []) // rendering // = - if (!state) { + if (!isActive) { return <View /> } @@ -41,15 +68,18 @@ export function Composer({}: {winHeight: number}) { quote={state.quote} onPost={state.onPost} mention={state.mention} + openPicker={onOpenPicker} /> </Animated.View> + <EmojiPicker state={pickerState} close={onClosePicker} /> </Animated.View> ) } const styles = StyleSheet.create({ mask: { - position: 'absolute', + // @ts-ignore + position: 'fixed', top: 0, left: 0, width: '100%', diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 14bc6af26..c30874c2f 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -53,6 +53,8 @@ import {useInviteCodesQuery} from '#/state/queries/invites' import {NavSignupCard} from '#/view/shell/NavSignupCard' import {TextLink} from '../com/util/Link' +import {useTheme as useAlfTheme} from '#/alf' + let DrawerProfileCard = ({ account, onPressProfile, @@ -68,7 +70,7 @@ let DrawerProfileCard = ({ <TouchableOpacity testID="profileCardButton" accessibilityLabel={_(msg`Profile`)} - accessibilityHint="Navigates to your profile" + accessibilityHint={_(msg`Navigates to your profile`)} onPress={onPressProfile}> <UserAvatar size={80} @@ -106,6 +108,7 @@ export {DrawerProfileCard} let DrawerContent = ({}: {}): React.ReactNode => { const theme = useTheme() + const t = useAlfTheme() const pal = usePalette('default') const {_} = useLingui() const setDrawerOpen = useSetDrawerOpen() @@ -208,7 +211,7 @@ let DrawerContent = ({}: {}): React.ReactNode => { testID="drawer" style={[ styles.view, - theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode, + theme.colorScheme === 'light' ? pal.view : t.atoms.bg_contrast_25, ]}> <SafeAreaView style={s.flex1}> <ScrollView style={styles.main}> @@ -435,7 +438,9 @@ let NotificationsMenuItem = ({ label={_(msg`Notifications`)} accessibilityLabel={_(msg`Notifications`)} accessibilityHint={ - numUnreadNotifications === '' ? '' : `${numUnreadNotifications} unread` + numUnreadNotifications === '' + ? '' + : _(msg`${numUnreadNotifications} unread`) } count={numUnreadNotifications} bold={isActive} diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx index ae9381440..f226406f5 100644 --- a/src/view/shell/bottom-bar/BottomBarStyles.tsx +++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx @@ -12,6 +12,10 @@ export const styles = StyleSheet.create({ paddingLeft: 5, paddingRight: 10, }, + bottomBarWeb: { + // @ts-ignore web-only + position: 'fixed', + }, ctrl: { flex: 1, paddingTop: 13, diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index c5dc376b7..b330c4b80 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -57,6 +57,7 @@ export function BottomBarWeb() { <Animated.View style={[ styles.bottomBar, + styles.bottomBarWeb, pal.view, pal.border, {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)}, diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx index 43dc28159..9fea6e49f 100644 --- a/src/view/shell/createNativeStackNavigatorWithAuth.tsx +++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx @@ -124,6 +124,7 @@ function NativeStackNavigator({ }, } } + return ( <NavigationContent> <NativeStackView @@ -136,7 +137,7 @@ function NativeStackNavigator({ {isWeb && !isMobile && ( <> <DesktopLeftNav /> - <DesktopRightNav /> + <DesktopRightNav routeName={activeRoute.name} /> </> )} </NavigationContent> diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index 93b96e704..a8f5f1c66 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -21,7 +21,7 @@ export function DesktopFeeds() { }) return ( - <View style={[styles.container, pal.view, pal.border]}> + <View style={[styles.container, pal.view]}> <FeedItem href="/" title="Following" current={route.name === 'Home'} /> {feeds .filter(f => f.displayName !== 'Following') @@ -91,7 +91,5 @@ const styles = StyleSheet.create({ width: 300, paddingHorizontal: 12, paddingVertical: 18, - borderTopWidth: 1, - borderBottomWidth: 1, }, }) diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index f3c8c1d11..b27898828 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -239,24 +239,26 @@ function ComposeBtn() { return null } return ( - <TouchableOpacity - disabled={isFetchingHandle} - style={[styles.newPostBtn]} - onPress={onPressCompose} - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint=""> - <View style={styles.newPostBtnIconWrapper}> - <ComposeIcon2 - size={19} - strokeWidth={2} - style={styles.newPostBtnLabel} - /> - </View> - <Text type="button" style={styles.newPostBtnLabel}> - <Trans>New Post</Trans> - </Text> - </TouchableOpacity> + <View style={styles.newPostBtnContainer}> + <TouchableOpacity + disabled={isFetchingHandle} + style={styles.newPostBtn} + onPress={onPressCompose} + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint=""> + <View style={styles.newPostBtnIconWrapper}> + <ComposeIcon2 + size={19} + strokeWidth={2} + style={styles.newPostBtnLabel} + /> + </View> + <Text type="button" style={styles.newPostBtnLabel}> + <Trans context="action">New Post</Trans> + </Text> + </TouchableOpacity> + </View> ) } @@ -440,10 +442,11 @@ export function DesktopLeftNav() { const styles = StyleSheet.create({ leftNav: { - position: 'absolute', + // @ts-ignore web only + position: 'fixed', top: 10, // @ts-ignore web only - right: 'calc(50vw + 312px)', + left: 'calc(50vw - 300px - 220px - 20px)', width: 220, // @ts-ignore web only maxHeight: 'calc(100vh - 10px)', @@ -512,6 +515,9 @@ const styles = StyleSheet.create({ fontSize: 14, }, + newPostBtnContainer: { + flexDirection: 'row', + }, newPostBtn: { flexDirection: 'row', alignItems: 'center', diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index 8d9961a5f..328c527e4 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -16,7 +16,7 @@ import {Plural, Trans, msg, plural} from '@lingui/macro' import {useSession} from '#/state/session' import {useInviteCodesQuery} from '#/state/queries/invites' -export function DesktopRightNav() { +export function DesktopRightNav({routeName}: {routeName: string}) { const pal = usePalette('default') const palError = usePalette('error') const {_} = useLingui() @@ -30,12 +30,20 @@ export function DesktopRightNav() { return ( <View style={[styles.rightNav, pal.view]}> <View style={{paddingVertical: 20}}> - <DesktopSearch /> - - {hasSession && ( - <View style={{paddingTop: 18, marginBottom: 18}}> + {routeName === 'Search' ? ( + <View style={{marginBottom: 18}}> <DesktopFeeds /> </View> + ) : ( + <> + <DesktopSearch /> + + {hasSession && ( + <View style={[pal.border, styles.desktopFeedsContainer]}> + <DesktopFeeds /> + </View> + )} + </> )} <View @@ -48,7 +56,7 @@ export function DesktopRightNav() { {isSandbox ? ( <View style={[palError.view, styles.messageLine, s.p10]}> <Text type="md" style={[palError.text, s.bold]}> - SANDBOX. Posts and accounts are not permanent. + <Trans>SANDBOX. Posts and accounts are not permanent.</Trans> </Text> </View> ) : undefined} @@ -169,17 +177,18 @@ function InviteCodes() { const styles = StyleSheet.create({ rightNav: { - position: 'absolute', // @ts-ignore web only - left: 'calc(50vw + 320px)', - width: 304, + position: 'fixed', + // @ts-ignore web only + left: 'calc(50vw + 300px + 20px)', + width: 300, maxHeight: '100%', overflowY: 'auto', }, message: { paddingVertical: 18, - paddingHorizontal: 10, + paddingHorizontal: 12, }, messageLine: { marginBottom: 10, @@ -187,7 +196,7 @@ const styles = StyleSheet.create({ inviteCodes: { borderTopWidth: 1, - paddingHorizontal: 16, + paddingHorizontal: 12, paddingVertical: 12, flexDirection: 'row', }, @@ -196,4 +205,10 @@ const styles = StyleSheet.create({ marginRight: 6, flexShrink: 0, }, + desktopFeedsContainer: { + borderTopWidth: 1, + borderBottomWidth: 1, + marginTop: 18, + marginBottom: 18, + }, }) diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 6201f828f..4a9483733 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -29,13 +29,63 @@ import {UserAvatar} from '#/view/com/util/UserAvatar' import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' import {useModerationOpts} from '#/state/queries/preferences' -export function SearchResultCard({ - profile, +export const MATCH_HANDLE = + /@?([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))/ + +export function SearchLinkCard({ + label, + to, + onPress, style, +}: { + label: string + to?: string + onPress?: () => void + style?: ViewStyle +}) { + const pal = usePalette('default') + + const inner = ( + <View + style={[pal.border, {paddingVertical: 16, paddingHorizontal: 12}, style]}> + <Text type="md" style={[pal.text]}> + {label} + </Text> + </View> + ) + + if (onPress) { + return ( + <TouchableOpacity + onPress={onPress} + accessibilityLabel={label} + accessibilityHint=""> + {inner} + </TouchableOpacity> + ) + } + + return ( + <Link href={to} asAnchor anchorNoUnderline> + <View + style={[ + pal.border, + {paddingVertical: 16, paddingHorizontal: 12}, + style, + ]}> + <Text type="md" style={[pal.text]}> + {label} + </Text> + </View> + </Link> + ) +} + +export function SearchProfileCard({ + profile, moderation, }: { profile: AppBskyActorDefs.ProfileViewBasic - style: ViewStyle moderation: ProfileModeration }) { const pal = usePalette('default') @@ -50,9 +100,7 @@ export function SearchResultCard({ <View style={[ pal.border, - style, { - borderTopWidth: 1, flexDirection: 'row', alignItems: 'center', gap: 12, @@ -147,6 +195,11 @@ export function DesktopSearch() { navigation.dispatch(StackActions.push('Search', {q: query})) }, [query, navigation, setSearchResults]) + const queryMaybeHandle = React.useMemo(() => { + const match = MATCH_HANDLE.exec(query) + return match && match[1] + }, [query]) + return ( <View style={[styles.container, pal.view]}> <View @@ -169,6 +222,9 @@ export function DesktopSearch() { accessibilityRole="search" accessibilityLabel={_(msg`Search`)} accessibilityHint="" + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" /> {query ? ( <View style={styles.cancelBtn}> @@ -176,7 +232,7 @@ export function DesktopSearch() { onPress={onPressCancelSearch} accessibilityRole="button" accessibilityLabel={_(msg`Cancel search`)} - accessibilityHint="Exits inputting search query" + accessibilityHint={_(msg`Exits inputting search query`)} onAccessibilityEscape={onPressCancelSearch}> <Text type="lg" style={[pal.link]}> <Trans>Cancel</Trans> @@ -195,22 +251,26 @@ export function DesktopSearch() { </View> ) : ( <> - {searchResults.length ? ( - searchResults.map((item, i) => ( - <SearchResultCard - key={item.did} - profile={item} - moderation={moderateProfile(item, moderationOpts)} - style={i === 0 ? {borderTopWidth: 0} : {}} - /> - )) - ) : ( - <View> - <Text style={[pal.textLight, styles.noResults]}> - <Trans>No results found for {query}</Trans> - </Text> - </View> - )} + <SearchLinkCard + label={_(msg`Search for "${query}"`)} + to={`/search?q=${encodeURIComponent(query)}`} + style={{borderBottomWidth: 1}} + /> + + {queryMaybeHandle ? ( + <SearchLinkCard + label={_(msg`Go to @${queryMaybeHandle}`)} + to={`/profile/${queryMaybeHandle}`} + /> + ) : null} + + {searchResults.map(item => ( + <SearchProfileCard + key={item.did} + profile={item} + moderation={moderateProfile(item, moderationOpts)} + /> + ))} </> )} </View> diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 51c03ae3d..5320aebfc 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -28,6 +28,7 @@ import {isAndroid} from 'platform/detection' import {useSession} from '#/state/session' import {useCloseAnyActiveElement} from '#/state/util' import * as notifications from 'lib/notifications/notifications' +import {Outlet as PortalOutlet} from '#/components/Portal' function ShellInner() { const isDrawerOpen = useIsDrawerOpen() @@ -94,6 +95,7 @@ function ShellInner() { </View> <Composer winHeight={winDim.height} /> <ModalsContainer /> + <PortalOutlet /> <Lightbox /> </> ) diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 38da860bd..76f4f5c9b 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -15,6 +15,8 @@ import {useAuxClick} from 'lib/hooks/useAuxClick' import {t} from '@lingui/macro' import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' import {useCloseAllActiveElements} from '#/state/util' +import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' +import {Outlet as PortalOutlet} from '#/components/Portal' function ShellInner() { const isDrawerOpen = useIsDrawerOpen() @@ -23,6 +25,7 @@ function ShellInner() { const navigator = useNavigation<NavigationProp>() const closeAllActiveElements = useCloseAllActiveElements() + useWebBodyScrollLock(isDrawerOpen) useAuxClick() useEffect(() => { @@ -33,27 +36,26 @@ function ShellInner() { }, [navigator, closeAllActiveElements]) return ( - <View style={[s.hContentRegion, {overflow: 'hidden'}]}> - <View style={s.hContentRegion}> - <ErrorBoundary> - <FlatNavigator /> - </ErrorBoundary> - </View> + <> + <ErrorBoundary> + <FlatNavigator /> + </ErrorBoundary> <Composer winHeight={0} /> <ModalsContainer /> + <PortalOutlet /> <Lightbox /> {!isDesktop && isDrawerOpen && ( <TouchableOpacity onPress={() => setDrawerOpen(false)} style={styles.drawerMask} accessibilityLabel={t`Close navigation footer`} - accessibilityHint="Closes bottom navigation bar"> + accessibilityHint={t`Closes bottom navigation bar`}> <View style={styles.drawerContainer}> <DrawerContent /> </View> </TouchableOpacity> )} - </View> + </> ) } @@ -76,7 +78,8 @@ const styles = StyleSheet.create({ backgroundColor: colors.black, // TODO }, drawerMask: { - position: 'absolute', + // @ts-ignore web only + position: 'fixed', width: '100%', height: '100%', top: 0, @@ -85,7 +88,8 @@ const styles = StyleSheet.create({ }, drawerContainer: { display: 'flex', - position: 'absolute', + // @ts-ignore web only + position: 'fixed', top: 0, left: 0, height: '100%', |