diff options
Diffstat (limited to 'src/view/com')
151 files changed, 7276 insertions, 5813 deletions
diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index 3e2c9c1bf..030ae68b1 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -1,15 +1,19 @@ import React from 'react' -import {SafeAreaView} from 'react-native' -import {observer} from 'mobx-react-lite' +import {View, Pressable} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' + +import {isIOS} from 'platform/detection' import {Login} from 'view/com/auth/login/Login' import {CreateAccount} from 'view/com/auth/create/CreateAccount' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {useAnalytics} from 'lib/analytics/analytics' import {SplashScreen} from './SplashScreen' import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' enum ScreenState { S_LoginOrCreateAccount, @@ -17,35 +21,66 @@ enum ScreenState { S_CreateAccount, } -export const LoggedOut = observer(function LoggedOutImpl() { +export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { + const {_} = useLingui() const pal = usePalette('default') - const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() const {screen} = useAnalytics() const [screenState, setScreenState] = React.useState<ScreenState>( ScreenState.S_LoginOrCreateAccount, ) + const {isMobile} = useWebMediaQueries() React.useEffect(() => { screen('Login') setMinimalShellMode(true) }, [screen, setMinimalShellMode]) - if ( - store.session.isResumingSession || - screenState === ScreenState.S_LoginOrCreateAccount - ) { - return ( - <SplashScreen - onPressSignin={() => setScreenState(ScreenState.S_Login)} - onPressCreateAccount={() => setScreenState(ScreenState.S_CreateAccount)} - /> - ) - } - return ( - <SafeAreaView testID="noSessionView" style={[s.hContentRegion, pal.view]}> + <View + testID="noSessionView" + style={[ + s.hContentRegion, + pal.view, + { + // only needed if dismiss button is present + paddingTop: onDismiss && isMobile ? 40 : 0, + }, + ]}> <ErrorBoundary> + {onDismiss && ( + <Pressable + accessibilityHint={_(msg`Go back`)} + accessibilityLabel={_(msg`Go back`)} + accessibilityRole="button" + style={{ + position: 'absolute', + top: isIOS ? 0 : 20, + right: 20, + padding: 10, + zIndex: 100, + backgroundColor: pal.text.color, + borderRadius: 100, + }} + onPress={onDismiss}> + <FontAwesomeIcon + icon="x" + size={12} + style={{ + color: String(pal.textInverted.color), + }} + /> + </Pressable> + )} + + {screenState === ScreenState.S_LoginOrCreateAccount ? ( + <SplashScreen + onPressSignin={() => setScreenState(ScreenState.S_Login)} + onPressCreateAccount={() => + setScreenState(ScreenState.S_CreateAccount) + } + /> + ) : undefined} {screenState === ScreenState.S_Login ? ( <Login onPressBack={() => @@ -61,6 +96,6 @@ export const LoggedOut = observer(function LoggedOutImpl() { /> ) : undefined} </ErrorBoundary> - </SafeAreaView> + </View> ) -}) +} diff --git a/src/view/com/auth/Onboarding.tsx b/src/view/com/auth/Onboarding.tsx index bec1dc236..bdb7f27c8 100644 --- a/src/view/com/auth/Onboarding.tsx +++ b/src/view/com/auth/Onboarding.tsx @@ -1,40 +1,51 @@ import React from 'react' -import {SafeAreaView} from 'react-native' -import {observer} from 'mobx-react-lite' +import {SafeAreaView, Platform} from 'react-native' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {Welcome} from './onboarding/Welcome' import {RecommendedFeeds} from './onboarding/RecommendedFeeds' import {RecommendedFollows} from './onboarding/RecommendedFollows' import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' +import {useOnboardingState, useOnboardingDispatch} from '#/state/shell' -export const Onboarding = observer(function OnboardingImpl() { +export function Onboarding() { const pal = usePalette('default') - const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() + const onboardingState = useOnboardingState() + const onboardingDispatch = useOnboardingDispatch() React.useEffect(() => { setMinimalShellMode(true) }, [setMinimalShellMode]) - const next = () => store.onboarding.next() - const skip = () => store.onboarding.skip() + const next = () => onboardingDispatch({type: 'next'}) + const skip = () => onboardingDispatch({type: 'skip'}) return ( - <SafeAreaView testID="onboardingView" style={[s.hContentRegion, pal.view]}> + <SafeAreaView + testID="onboardingView" + style={[ + s.hContentRegion, + pal.view, + // @ts-ignore web only -esb + Platform.select({ + web: { + height: '100vh', + }, + }), + ]}> <ErrorBoundary> - {store.onboarding.step === 'Welcome' && ( + {onboardingState.step === 'Welcome' && ( <Welcome skip={skip} next={next} /> )} - {store.onboarding.step === 'RecommendedFeeds' && ( + {onboardingState.step === 'RecommendedFeeds' && ( <RecommendedFeeds next={next} /> )} - {store.onboarding.step === 'RecommendedFollows' && ( + {onboardingState.step === 'RecommendedFollows' && ( <RecommendedFollows next={next} /> )} </ErrorBoundary> </SafeAreaView> ) -}) +} diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx index 67453f111..d88627f65 100644 --- a/src/view/com/auth/SplashScreen.tsx +++ b/src/view/com/auth/SplashScreen.tsx @@ -1,10 +1,12 @@ import React from 'react' -import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native' +import {StyleSheet, TouchableOpacity, View} from 'react-native' import {Text} from 'view/com/util/text/Text' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {CenteredView} from '../util/Views' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export const SplashScreen = ({ onPressSignin, @@ -14,40 +16,44 @@ export const SplashScreen = ({ onPressCreateAccount: () => void }) => { const pal = usePalette('default') + const {_} = useLingui() + return ( <CenteredView style={[styles.container, pal.view]}> - <SafeAreaView testID="noSessionView" style={styles.container}> - <ErrorBoundary> - <View style={styles.hero}> - <Text style={[styles.title, pal.link]}>Bluesky</Text> - <Text style={[styles.subtitle, pal.textLight]}> - See what's next + <ErrorBoundary> + <View style={styles.hero}> + <Text style={[styles.title, pal.link]}> + <Trans>Bluesky</Trans> + </Text> + <Text style={[styles.subtitle, pal.textLight]}> + <Trans>See what's next</Trans> + </Text> + </View> + <View testID="signinOrCreateAccount" style={styles.btns}> + <TouchableOpacity + testID="createAccountButton" + style={[styles.btn, {backgroundColor: colors.blue3}]} + onPress={onPressCreateAccount} + accessibilityRole="button" + accessibilityLabel={_(msg`Create new account`)} + accessibilityHint="Opens flow to create a new Bluesky account"> + <Text style={[s.white, styles.btnLabel]}> + <Trans>Create a new account</Trans> + </Text> + </TouchableOpacity> + <TouchableOpacity + testID="signInButton" + style={[styles.btn, pal.btn]} + onPress={onPressSignin} + accessibilityRole="button" + accessibilityLabel={_(msg`Sign in`)} + accessibilityHint="Opens flow to sign into your existing Bluesky account"> + <Text style={[pal.text, styles.btnLabel]}> + <Trans>Sign In</Trans> </Text> - </View> - <View testID="signinOrCreateAccount" style={styles.btns}> - <TouchableOpacity - testID="createAccountButton" - style={[styles.btn, {backgroundColor: colors.blue3}]} - onPress={onPressCreateAccount} - accessibilityRole="button" - accessibilityLabel="Create new account" - accessibilityHint="Opens flow to create a new Bluesky account"> - <Text style={[s.white, styles.btnLabel]}> - Create a new account - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="signInButton" - style={[styles.btn, pal.btn]} - onPress={onPressSignin} - accessibilityRole="button" - accessibilityLabel="Sign in" - accessibilityHint="Opens flow to sign into your existing Bluesky account"> - <Text style={[pal.text, styles.btnLabel]}>Sign In</Text> - </TouchableOpacity> - </View> - </ErrorBoundary> - </SafeAreaView> + </TouchableOpacity> + </View> + </ErrorBoundary> </CenteredView> ) } diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx index cef9618ef..08cf701da 100644 --- a/src/view/com/auth/SplashScreen.web.tsx +++ b/src/view/com/auth/SplashScreen.web.tsx @@ -1,5 +1,6 @@ import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {StyleSheet, TouchableOpacity, View, Pressable} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from 'view/com/util/text/Text' import {TextLink} from '../util/Link' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' @@ -8,11 +9,14 @@ import {usePalette} from 'lib/hooks/usePalette' import {CenteredView} from '../util/Views' import {isWeb} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Trans} from '@lingui/macro' export const SplashScreen = ({ + onDismiss, onPressSignin, onPressCreateAccount, }: { + onDismiss?: () => void onPressSignin: () => void onPressCreateAccount: () => void }) => { @@ -22,45 +26,70 @@ export const SplashScreen = ({ const isMobileWeb = isWeb && isTabletOrMobile return ( - <CenteredView style={[styles.container, pal.view]}> - <View - testID="noSessionView" - style={[ - styles.containerInner, - isMobileWeb && styles.containerInnerMobile, - pal.border, - ]}> - <ErrorBoundary> - <Text style={isMobileWeb ? styles.titleMobile : styles.title}> - Bluesky - </Text> - <Text style={isMobileWeb ? styles.subtitleMobile : styles.subtitle}> - See what's next - </Text> - <View testID="signinOrCreateAccount" style={styles.btns}> - <TouchableOpacity - testID="createAccountButton" - style={[styles.btn, {backgroundColor: colors.blue3}]} - onPress={onPressCreateAccount} - // TODO: web accessibility - accessibilityRole="button"> - <Text style={[s.white, styles.btnLabel]}> - Create a new account - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="signInButton" - style={[styles.btn, pal.btn]} - onPress={onPressSignin} - // TODO: web accessibility - accessibilityRole="button"> - <Text style={[pal.text, styles.btnLabel]}>Sign In</Text> - </TouchableOpacity> - </View> - </ErrorBoundary> - </View> - <Footer styles={styles} /> - </CenteredView> + <> + {onDismiss && ( + <Pressable + accessibilityRole="button" + style={{ + position: 'absolute', + top: 20, + right: 20, + padding: 20, + zIndex: 100, + }} + onPress={onDismiss}> + <FontAwesomeIcon + icon="x" + size={24} + style={{ + color: String(pal.text.color), + }} + /> + </Pressable> + )} + + <CenteredView style={[styles.container, pal.view]}> + <View + testID="noSessionView" + style={[ + styles.containerInner, + isMobileWeb && styles.containerInnerMobile, + pal.border, + ]}> + <ErrorBoundary> + <Text style={isMobileWeb ? styles.titleMobile : styles.title}> + Bluesky + </Text> + <Text style={isMobileWeb ? styles.subtitleMobile : styles.subtitle}> + See what's next + </Text> + <View testID="signinOrCreateAccount" style={styles.btns}> + <TouchableOpacity + testID="createAccountButton" + style={[styles.btn, {backgroundColor: colors.blue3}]} + onPress={onPressCreateAccount} + // TODO: web accessibility + accessibilityRole="button"> + <Text style={[s.white, styles.btnLabel]}> + Create a new account + </Text> + </TouchableOpacity> + <TouchableOpacity + testID="signInButton" + style={[styles.btn, pal.btn]} + onPress={onPressSignin} + // TODO: web accessibility + accessibilityRole="button"> + <Text style={[pal.text, styles.btnLabel]}> + <Trans>Sign In</Trans> + </Text> + </TouchableOpacity> + </View> + </ErrorBoundary> + </View> + <Footer styles={styles} /> + </CenteredView> + </> ) } diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 1d64cc067..ab6d34584 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -7,78 +7,134 @@ import { TouchableOpacity, View, } from 'react-native' -import {observer} from 'mobx-react-lite' import {useAnalytics} from 'lib/analytics/analytics' import {Text} from '../../util/text/Text' import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' import {s} from 'lib/styles' -import {useStores} from 'state/index' -import {CreateAccountModel} from 'state/models/ui/create-account' import {usePalette} from 'lib/hooks/usePalette' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useOnboardingDispatch} from '#/state/shell' +import {useSessionApi} from '#/state/session' +import {useCreateAccount, submit} from './state' +import {useServiceQuery} from '#/state/queries/service' +import { + usePreferencesSetBirthDateMutation, + useSetSaveFeedsMutation, + DEFAULT_PROD_FEEDS, +} from '#/state/queries/preferences' +import {IS_PROD} from '#/lib/constants' import {Step1} from './Step1' import {Step2} from './Step2' import {Step3} from './Step3' -export const CreateAccount = observer(function CreateAccountImpl({ - onPressBack, -}: { - onPressBack: () => void -}) { +export function CreateAccount({onPressBack}: {onPressBack: () => void}) { const {track, screen} = useAnalytics() const pal = usePalette('default') - const store = useStores() - const model = React.useMemo(() => new CreateAccountModel(store), [store]) + const {_} = useLingui() + const [uiState, uiDispatch] = useCreateAccount() + const onboardingDispatch = useOnboardingDispatch() + const {createAccount} = useSessionApi() + const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation() + const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() React.useEffect(() => { screen('CreateAccount') }, [screen]) + // fetch service info + // = + + const { + data: serviceInfo, + isFetching: serviceInfoIsFetching, + error: serviceInfoError, + refetch: refetchServiceInfo, + } = useServiceQuery(uiState.serviceUrl) + React.useEffect(() => { - model.fetchServiceDescription() - }, [model]) + if (serviceInfo) { + uiDispatch({type: 'set-service-description', value: serviceInfo}) + uiDispatch({type: 'set-error', value: ''}) + } else if (serviceInfoError) { + uiDispatch({ + type: 'set-error', + value: _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + }) + } + }, [_, uiDispatch, serviceInfo, serviceInfoError]) - const onPressRetryConnect = React.useCallback( - () => model.fetchServiceDescription(), - [model], - ) + // event handlers + // = const onPressBackInner = React.useCallback(() => { - if (model.canBack) { - model.back() + if (uiState.canBack) { + uiDispatch({type: 'back'}) } else { onPressBack() } - }, [model, onPressBack]) + }, [uiState, uiDispatch, onPressBack]) const onPressNext = React.useCallback(async () => { - if (!model.canNext) { + if (!uiState.canNext) { return } - if (model.step < 3) { - model.next() + if (uiState.step < 3) { + uiDispatch({type: 'next'}) } else { try { - await model.submit() + await submit({ + onboardingDispatch, + createAccount, + uiState, + uiDispatch, + _, + }) + track('Create Account') + setBirthDate({birthDate: uiState.birthDate}) + if (IS_PROD(uiState.serviceUrl)) { + setSavedFeeds(DEFAULT_PROD_FEEDS) + } } catch { // dont need to handle here } finally { track('Try Create Account') } } - }, [model, track]) + }, [ + uiState, + uiDispatch, + track, + onboardingDispatch, + createAccount, + setBirthDate, + setSavedFeeds, + _, + ]) + + // rendering + // = return ( <LoggedOutLayout - leadin={`Step ${model.step}`} - title="Create Account" - description="We're so excited to have you join us!"> + leadin={`Step ${uiState.step}`} + 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}> - {model.step === 1 && <Step1 model={model} />} - {model.step === 2 && <Step2 model={model} />} - {model.step === 3 && <Step3 model={model} />} + {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 @@ -86,40 +142,40 @@ export const CreateAccount = observer(function CreateAccountImpl({ testID="backBtn" accessibilityRole="button"> <Text type="xl" style={pal.link}> - Back + <Trans>Back</Trans> </Text> </TouchableOpacity> <View style={s.flex1} /> - {model.canNext ? ( + {uiState.canNext ? ( <TouchableOpacity testID="nextBtn" onPress={onPressNext} accessibilityRole="button"> - {model.isProcessing ? ( + {uiState.isProcessing ? ( <ActivityIndicator /> ) : ( <Text type="xl-bold" style={[pal.link, s.pr5]}> - Next + <Trans>Next</Trans> </Text> )} </TouchableOpacity> - ) : model.didServiceDescriptionFetchFail ? ( + ) : serviceInfoError ? ( <TouchableOpacity testID="retryConnectBtn" - onPress={onPressRetryConnect} + onPress={() => refetchServiceInfo()} accessibilityRole="button" - accessibilityLabel="Retry" - accessibilityHint="Retries account creation" + accessibilityLabel={_(msg`Retry`)} + accessibilityHint="" accessibilityLiveRegion="polite"> <Text type="xl-bold" style={[pal.link, s.pr5]}> - Retry + <Trans>Retry</Trans> </Text> </TouchableOpacity> - ) : model.isFetchingServiceDescription ? ( + ) : serviceInfoIsFetching ? ( <> <ActivityIndicator color="#fff" /> <Text type="xl" style={[pal.text, s.pr5]}> - Connecting... + <Trans>Connecting...</Trans> </Text> </> ) : undefined} @@ -129,7 +185,7 @@ export const CreateAccount = observer(function CreateAccountImpl({ </ScrollView> </LoggedOutLayout> ) -}) +} const styles = StyleSheet.create({ stepContainer: { diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx index 8eb669bcf..a52f07531 100644 --- a/src/view/com/auth/create/Policies.tsx +++ b/src/view/com/auth/create/Policies.tsx @@ -4,12 +4,14 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {ComAtprotoServerDescribeServer} from '@atproto/api' import {TextLink} from '../../util/Link' import {Text} from '../../util/text/Text' import {s, colors} from 'lib/styles' -import {ServiceDescription} from 'state/models/session' import {usePalette} from 'lib/hooks/usePalette' +type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + export const Policies = ({ serviceDescription, needsGuardian, @@ -93,7 +95,7 @@ function validWebLink(url?: string): string | undefined { const styles = StyleSheet.create({ policies: { - flexDirection: 'row', + flexDirection: 'column', gap: 8, }, errorIcon: { diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index cdd5cb21d..c9d19e868 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -1,10 +1,8 @@ import React from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import debounce from 'lodash.debounce' import {Text} from 'view/com/util/text/Text' import {StepHeader} from './StepHeader' -import {CreateAccountModel} from 'state/models/ui/create-account' +import {CreateAccountState, CreateAccountDispatch} from './state' import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' @@ -12,60 +10,49 @@ import {HelpTip} from '../util/HelpTip' import {TextInput} from '../util/TextInput' import {Button} from 'view/com/util/forms/Button' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' -import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index' +import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants' import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' /** STEP 1: Your hosting provider * @field Bluesky (default) * @field Other (staging, local dev, your own PDS, etc.) */ -export const Step1 = observer(function Step1Impl({ - model, +export function Step1({ + uiState, + uiDispatch, }: { - model: CreateAccountModel + uiState: CreateAccountState + uiDispatch: CreateAccountDispatch }) { const pal = usePalette('default') const [isDefaultSelected, setIsDefaultSelected] = React.useState(true) + const {_} = useLingui() const onPressDefault = React.useCallback(() => { setIsDefaultSelected(true) - model.setServiceUrl(PROD_SERVICE) - model.fetchServiceDescription() - }, [setIsDefaultSelected, model]) + uiDispatch({type: 'set-service-url', value: PROD_SERVICE}) + }, [setIsDefaultSelected, uiDispatch]) const onPressOther = React.useCallback(() => { setIsDefaultSelected(false) - model.setServiceUrl('https://') - model.setServiceDescription(undefined) - }, [setIsDefaultSelected, model]) - - const fetchServiceDescription = React.useMemo( - () => debounce(() => model.fetchServiceDescription(), 1e3), // debouce for 1 second (1e3 = 1000ms) - [model], - ) + uiDispatch({type: 'set-service-url', value: 'https://'}) + }, [setIsDefaultSelected, uiDispatch]) const onChangeServiceUrl = React.useCallback( (v: string) => { - model.setServiceUrl(v) - fetchServiceDescription() - }, - [model, fetchServiceDescription], - ) - - const onDebugChangeServiceUrl = React.useCallback( - (v: string) => { - model.setServiceUrl(v) - model.fetchServiceDescription() + uiDispatch({type: 'set-service-url', value: v}) }, - [model], + [uiDispatch], ) return ( <View> - <StepHeader step="1" title="Your hosting provider" /> + <StepHeader step="1" title={_(msg`Your hosting provider`)} /> <Text style={[pal.text, s.mb10]}> - This is the service that keeps you online. + <Trans>This is the service that keeps you online.</Trans> </Text> <Option testID="blueskyServerBtn" @@ -81,17 +68,17 @@ export const Step1 = observer(function Step1Impl({ onPress={onPressOther}> <View style={styles.otherForm}> <Text nativeID="addressProvider" style={[pal.text, s.mb5]}> - Enter the address of your provider: + <Trans>Enter the address of your provider:</Trans> </Text> <TextInput testID="customServerInput" icon="globe" - placeholder="Hosting provider address" - value={model.serviceUrl} + placeholder={_(msg`Hosting provider address`)} + value={uiState.serviceUrl} editable onChange={onChangeServiceUrl} accessibilityHint="Input hosting provider address" - accessibilityLabel="Hosting provider address" + accessibilityLabel={_(msg`Hosting provider address`)} accessibilityLabelledBy="addressProvider" /> {LOGIN_INCLUDE_DEV_SERVERS && ( @@ -100,27 +87,27 @@ export const Step1 = observer(function Step1Impl({ testID="stagingServerBtn" type="default" style={s.mr5} - label="Staging" - onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)} + label={_(msg`Staging`)} + onPress={() => onChangeServiceUrl(STAGING_SERVICE)} /> <Button testID="localDevServerBtn" type="default" - label="Dev Server" - onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)} + label={_(msg`Dev Server`)} + onPress={() => onChangeServiceUrl(LOCAL_DEV_SERVICE)} /> </View> )} </View> </Option> - {model.error ? ( - <ErrorMessage message={model.error} style={styles.error} /> + {uiState.error ? ( + <ErrorMessage message={uiState.error} style={styles.error} /> ) : ( - <HelpTip text="You can change hosting providers at any time." /> + <HelpTip text={_(msg`You can change hosting providers at any time.`)} /> )} </View> ) -}) +} function Option({ children, diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index 60e197564..89fd070ad 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -1,7 +1,6 @@ import React from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {CreateAccountModel} from 'state/models/ui/create-account' +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' @@ -10,8 +9,10 @@ import {usePalette} from 'lib/hooks/usePalette' import {TextInput} from '../util/TextInput' import {Policies} from './Policies' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' -import {useStores} from 'state/index' import {isWeb} from 'platform/detection' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' /** STEP 2: Your account * @field Invite code or waitlist @@ -22,23 +23,26 @@ import {isWeb} from 'platform/detection' * @field Birth date * @readonly Terms of service & privacy policy */ -export const Step2 = observer(function Step2Impl({ - model, +export function Step2({ + uiState, + uiDispatch, }: { - model: CreateAccountModel + uiState: CreateAccountState + uiDispatch: CreateAccountDispatch }) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const {openModal} = useModalControls() const onPressWaitlist = React.useCallback(() => { - store.shell.openModal({name: 'waitlist'}) - }, [store]) + openModal({name: 'waitlist'}) + }, [openModal]) return ( <View> - <StepHeader step="2" title="Your account" /> + <StepHeader step="2" title={_(msg`Your account`)} /> - {model.isInviteCodeRequired && ( + {uiState.isInviteCodeRequired && ( <View style={s.pb20}> <Text type="md-medium" style={[pal.text, s.mb2]}> Invite code @@ -46,25 +50,27 @@ export const Step2 = observer(function Step2Impl({ <TextInput testID="inviteCodeInput" icon="ticket" - placeholder="Required for this provider" - value={model.inviteCode} + placeholder={_(msg`Required for this provider`)} + value={uiState.inviteCode} editable - onChange={model.setInviteCode} - accessibilityLabel="Invite code" + onChange={value => uiDispatch({type: 'set-invite-code', value})} + accessibilityLabel={_(msg`Invite code`)} accessibilityHint="Input invite code to proceed" /> </View> )} - {!model.inviteCode && model.isInviteCodeRequired ? ( + {!uiState.inviteCode && uiState.isInviteCodeRequired ? ( <Text style={[s.alignBaseline, pal.text]}> Don't have an invite code?{' '} <TouchableWithoutFeedback onPress={onPressWaitlist} - accessibilityLabel="Join the waitlist." + accessibilityLabel={_(msg`Join the waitlist.`)} accessibilityHint=""> <View style={styles.touchable}> - <Text style={pal.link}>Join the waitlist.</Text> + <Text style={pal.link}> + <Trans>Join the waitlist.</Trans> + </Text> </View> </TouchableWithoutFeedback> </Text> @@ -72,16 +78,16 @@ export const Step2 = observer(function Step2Impl({ <> <View style={s.pb20}> <Text type="md-medium" style={[pal.text, s.mb2]} nativeID="email"> - Email address + <Trans>Email address</Trans> </Text> <TextInput testID="emailInput" icon="envelope" - placeholder="Enter your email address" - value={model.email} + placeholder={_(msg`Enter your email address`)} + value={uiState.email} editable - onChange={model.setEmail} - accessibilityLabel="Email" + onChange={value => uiDispatch({type: 'set-email', value})} + accessibilityLabel={_(msg`Email`)} accessibilityHint="Input email for Bluesky waitlist" accessibilityLabelledBy="email" /> @@ -92,17 +98,17 @@ export const Step2 = observer(function Step2Impl({ type="md-medium" style={[pal.text, s.mb2]} nativeID="password"> - Password + <Trans>Password</Trans> </Text> <TextInput testID="passwordInput" icon="lock" - placeholder="Choose your password" - value={model.password} + placeholder={_(msg`Choose your password`)} + value={uiState.password} editable secureTextEntry - onChange={model.setPassword} - accessibilityLabel="Password" + onChange={value => uiDispatch({type: 'set-password', value})} + accessibilityLabel={_(msg`Password`)} accessibilityHint="Set password" accessibilityLabelledBy="password" /> @@ -113,35 +119,35 @@ export const Step2 = observer(function Step2Impl({ type="md-medium" style={[pal.text, s.mb2]} nativeID="birthDate"> - Your birth date + <Trans>Your birth date</Trans> </Text> <DateInput testID="birthdayInput" - value={model.birthDate} - onChange={model.setBirthDate} + value={uiState.birthDate} + onChange={value => uiDispatch({type: 'set-birth-date', value})} buttonType="default-light" buttonStyle={[pal.border, styles.dateInputButton]} buttonLabelType="lg" - accessibilityLabel="Birthday" + accessibilityLabel={_(msg`Birthday`)} accessibilityHint="Enter your birth date" accessibilityLabelledBy="birthDate" /> </View> - {model.serviceDescription && ( + {uiState.serviceDescription && ( <Policies - serviceDescription={model.serviceDescription} - needsGuardian={!model.isAge18} + serviceDescription={uiState.serviceDescription} + needsGuardian={!is18(uiState)} /> )} </> )} - {model.error ? ( - <ErrorMessage message={model.error} style={styles.error} /> + {uiState.error ? ( + <ErrorMessage message={uiState.error} style={styles.error} /> ) : undefined} </View> ) -}) +} const styles = StyleSheet.create({ error: { diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx index beb756ac1..3b628b6b6 100644 --- a/src/view/com/auth/create/Step3.tsx +++ b/src/view/com/auth/create/Step3.tsx @@ -1,7 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {CreateAccountModel} from 'state/models/ui/create-account' +import {CreateAccountState, CreateAccountDispatch} from './state' import {Text} from 'view/com/util/text/Text' import {StepHeader} from './StepHeader' import {s} from 'lib/styles' @@ -9,44 +8,49 @@ import {TextInput} from '../util/TextInput' import {createFullHandle} from 'lib/strings/handles' import {usePalette} from 'lib/hooks/usePalette' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' /** STEP 3: Your user handle * @field User handle */ -export const Step3 = observer(function Step3Impl({ - model, +export function Step3({ + uiState, + uiDispatch, }: { - model: CreateAccountModel + uiState: CreateAccountState + uiDispatch: CreateAccountDispatch }) { const pal = usePalette('default') + const {_} = useLingui() return ( <View> - <StepHeader step="3" title="Your user handle" /> + <StepHeader step="3" title={_(msg`Your user handle`)} /> <View style={s.pb10}> <TextInput testID="handleInput" icon="at" placeholder="e.g. alice" - value={model.handle} + value={uiState.handle} editable - onChange={model.setHandle} + onChange={value => uiDispatch({type: 'set-handle', value})} // TODO: Add explicit text label - accessibilityLabel="User handle" + accessibilityLabel={_(msg`User handle`)} accessibilityHint="Input your user handle" /> <Text type="lg" style={[pal.text, s.pl5, s.pt10]}> - Your full handle will be{' '} - <Text type="lg-bold" style={pal.text}> - @{createFullHandle(model.handle, model.userDomain)} + <Trans>Your full handle will be</Trans> + <Text type="lg-bold" style={[pal.text, s.ml5]}> + @{createFullHandle(uiState.handle, uiState.userDomain)} </Text> </Text> </View> - {model.error ? ( - <ErrorMessage message={model.error} style={styles.error} /> + {uiState.error ? ( + <ErrorMessage message={uiState.error} style={styles.error} /> ) : undefined} </View> ) -}) +} const styles = StyleSheet.create({ error: { diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts new file mode 100644 index 000000000..4df82f8fc --- /dev/null +++ b/src/view/com/auth/create/state.ts @@ -0,0 +1,242 @@ +import {useReducer} from 'react' +import { + ComAtprotoServerDescribeServer, + ComAtprotoServerCreateAccount, +} from '@atproto/api' +import {I18nContext, useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import * as EmailValidator from 'email-validator' +import {getAge} from 'lib/strings/time' +import {logger} from '#/logger' +import {createFullHandle} from '#/lib/strings/handles' +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' + +export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema +const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago + +export type CreateAccountAction = + | {type: 'set-step'; value: number} + | {type: 'set-error'; value: string | undefined} + | {type: 'set-processing'; value: boolean} + | {type: 'set-service-url'; value: string} + | {type: 'set-service-description'; value: ServiceDescription | undefined} + | {type: 'set-user-domain'; value: string} + | {type: 'set-invite-code'; value: string} + | {type: 'set-email'; value: string} + | {type: 'set-password'; value: string} + | {type: 'set-handle'; value: string} + | {type: 'set-birth-date'; value: Date} + | {type: 'next'} + | {type: 'back'} + +export interface CreateAccountState { + // state + step: number + error: string | undefined + isProcessing: boolean + serviceUrl: string + serviceDescription: ServiceDescription | undefined + userDomain: string + inviteCode: string + email: string + password: string + handle: string + birthDate: Date + + // computed + canBack: boolean + canNext: boolean + isInviteCodeRequired: boolean +} + +export type CreateAccountDispatch = (action: CreateAccountAction) => void + +export function useCreateAccount() { + const {_} = useLingui() + return useReducer(createReducer({_}), { + step: 1, + error: undefined, + isProcessing: false, + serviceUrl: DEFAULT_SERVICE, + serviceDescription: undefined, + userDomain: '', + inviteCode: '', + email: '', + password: '', + handle: '', + birthDate: DEFAULT_DATE, + + canBack: false, + canNext: false, + isInviteCodeRequired: false, + }) +} + +export async function submit({ + createAccount, + onboardingDispatch, + uiState, + uiDispatch, + _, +}: { + createAccount: SessionApiContext['createAccount'] + onboardingDispatch: OnboardingDispatchContext + uiState: CreateAccountState + uiDispatch: CreateAccountDispatch + _: I18nContext['_'] +}) { + if (!uiState.email) { + uiDispatch({type: 'set-step', value: 2}) + return uiDispatch({ + type: 'set-error', + value: _(msg`Please enter your email.`), + }) + } + if (!EmailValidator.validate(uiState.email)) { + uiDispatch({type: 'set-step', value: 2}) + return uiDispatch({ + type: 'set-error', + value: _(msg`Your email appears to be invalid.`), + }) + } + if (!uiState.password) { + uiDispatch({type: 'set-step', value: 2}) + return uiDispatch({ + type: 'set-error', + value: _(msg`Please choose your password.`), + }) + } + if (!uiState.handle) { + uiDispatch({type: 'set-step', value: 3}) + return uiDispatch({ + type: 'set-error', + value: _(msg`Please choose your handle.`), + }) + } + uiDispatch({type: 'set-error', value: ''}) + uiDispatch({type: 'set-processing', value: true}) + + try { + onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view + await createAccount({ + service: uiState.serviceUrl, + email: uiState.email, + handle: createFullHandle(uiState.handle, uiState.userDomain), + password: uiState.password, + inviteCode: uiState.inviteCode.trim(), + }) + } catch (e: any) { + onboardingDispatch({type: 'skip'}) // undo starting the onboard + let errMsg = e.toString() + if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { + errMsg = _( + msg`Invite code not accepted. Check that you input it correctly and try again.`, + ) + } + logger.error('Failed to create account', {error: e}) + uiDispatch({type: 'set-processing', value: false}) + uiDispatch({type: 'set-error', value: cleanError(errMsg)}) + throw e + } +} + +export function is13(state: CreateAccountState) { + return getAge(state.birthDate) >= 18 +} + +export function is18(state: CreateAccountState) { + return getAge(state.birthDate) >= 18 +} + +function createReducer({_}: {_: I18nContext['_']}) { + return function reducer( + state: CreateAccountState, + action: CreateAccountAction, + ): CreateAccountState { + switch (action.type) { + case 'set-step': { + return compute({...state, step: action.value}) + } + case 'set-error': { + return compute({...state, error: action.value}) + } + case 'set-processing': { + return compute({...state, isProcessing: action.value}) + } + case 'set-service-url': { + return compute({ + ...state, + serviceUrl: action.value, + serviceDescription: + state.serviceUrl !== action.value + ? undefined + : state.serviceDescription, + }) + } + case 'set-service-description': { + return compute({ + ...state, + serviceDescription: action.value, + userDomain: action.value?.availableUserDomains[0] || '', + }) + } + case 'set-user-domain': { + return compute({...state, userDomain: action.value}) + } + case 'set-invite-code': { + return compute({...state, inviteCode: action.value}) + } + case 'set-email': { + return compute({...state, email: action.value}) + } + case 'set-password': { + return compute({...state, password: action.value}) + } + case 'set-handle': { + return compute({...state, handle: action.value}) + } + case 'set-birth-date': { + return compute({...state, birthDate: action.value}) + } + case 'next': { + if (state.step === 2) { + if (!is13(state)) { + return compute({ + ...state, + error: _( + msg`Unfortunately, you do not meet the requirements to create an account.`, + ), + }) + } + } + return compute({...state, error: '', step: state.step + 1}) + } + case 'back': { + return compute({...state, error: '', step: state.step - 1}) + } + } + } +} + +function compute(state: CreateAccountState): CreateAccountState { + let canNext = true + if (state.step === 1) { + canNext = !!state.serviceDescription + } else if (state.step === 2) { + canNext = + (!state.isInviteCodeRequired || !!state.inviteCode) && + !!state.email && + !!state.password + } else if (state.step === 3) { + canNext = !!state.handle + } + return { + ...state, + canBack: state.step > 1, + canNext, + isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired, + } +} diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx new file mode 100644 index 000000000..73ddfc9d6 --- /dev/null +++ b/src/view/com/auth/login/ChooseAccountForm.tsx @@ -0,0 +1,158 @@ +import React from 'react' +import {ScrollView, TouchableOpacity, View} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {useAnalytics} from 'lib/analytics/analytics' +import {Text} from '../../util/text/Text' +import {UserAvatar} from '../../util/UserAvatar' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {styles} from './styles' +import {useSession, useSessionApi, SessionAccount} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import * as Toast from '#/view/com/util/Toast' + +function AccountItem({ + account, + onSelect, + isCurrentAccount, +}: { + account: SessionAccount + onSelect: (account: SessionAccount) => void + isCurrentAccount: boolean +}) { + const pal = usePalette('default') + const {_} = useLingui() + const {data: profile} = useProfileQuery({did: account.did}) + + const onPress = React.useCallback(() => { + onSelect(account) + }, [account, onSelect]) + + return ( + <TouchableOpacity + testID={`chooseAccountBtn-${account.handle}`} + key={account.did} + style={[pal.view, pal.border, styles.account]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={_(msg`Sign in as ${account.handle}`)} + accessibilityHint="Double tap to sign in"> + <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <View style={s.p10}> + <UserAvatar avatar={profile?.avatar} size={30} /> + </View> + <Text style={styles.accountText}> + <Text type="lg-bold" style={pal.text}> + {profile?.displayName || account.handle}{' '} + </Text> + <Text type="lg" style={[pal.textLight]}> + {account.handle} + </Text> + </Text> + {isCurrentAccount ? ( + <FontAwesomeIcon + icon="check" + size={16} + style={[{color: colors.green3} as FontAwesomeIconStyle, s.mr10]} + /> + ) : ( + <FontAwesomeIcon + icon="angle-right" + size={16} + style={[pal.text, s.mr10]} + /> + )} + </View> + </TouchableOpacity> + ) +} +export const ChooseAccountForm = ({ + onSelectAccount, + onPressBack, +}: { + onSelectAccount: (account?: SessionAccount) => void + onPressBack: () => void +}) => { + const {track, screen} = useAnalytics() + const pal = usePalette('default') + const {_} = useLingui() + const {accounts, currentAccount} = useSession() + const {initSession} = useSessionApi() + const {setShowLoggedOut} = useLoggedOutViewControls() + + React.useEffect(() => { + screen('Choose Account') + }, [screen]) + + const onSelect = React.useCallback( + async (account: SessionAccount) => { + if (account.accessJwt) { + if (account.did === currentAccount?.did) { + setShowLoggedOut(false) + Toast.show(`Already signed in as @${account.handle}`) + } else { + await initSession(account) + track('Sign In', {resumedSession: true}) + setTimeout(() => { + Toast.show(`Signed in as @${account.handle}`) + }, 100) + } + } else { + onSelectAccount(account) + } + }, + [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut], + ) + + return ( + <ScrollView testID="chooseAccountForm" style={styles.maxHeight}> + <Text + type="2xl-medium" + style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}> + <Trans>Sign in as...</Trans> + </Text> + {accounts.map(account => ( + <AccountItem + key={account.did} + account={account} + onSelect={onSelect} + isCurrentAccount={account.did === currentAccount?.did} + /> + ))} + <TouchableOpacity + testID="chooseNewAccountBtn" + style={[pal.view, pal.border, styles.account, styles.accountLast]} + onPress={() => onSelectAccount(undefined)} + accessibilityRole="button" + accessibilityLabel={_(msg`Login to account that is not listed`)} + accessibilityHint=""> + <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <Text style={[styles.accountText, styles.accountTextOther]}> + <Text type="lg" style={pal.text}> + <Trans>Other account</Trans> + </Text> + </Text> + <FontAwesomeIcon + icon="angle-right" + size={16} + style={[pal.text, s.mr10]} + /> + </View> + </TouchableOpacity> + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> + <Text type="xl" style={[pal.link, s.pl5]}> + <Trans>Back</Trans> + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + </View> + </ScrollView> + ) +} diff --git a/src/view/com/auth/login/ForgotPasswordForm.tsx b/src/view/com/auth/login/ForgotPasswordForm.tsx new file mode 100644 index 000000000..215c393d9 --- /dev/null +++ b/src/view/com/auth/login/ForgotPasswordForm.tsx @@ -0,0 +1,197 @@ +import React, {useState, useEffect} from 'react' +import { + ActivityIndicator, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {ComAtprotoServerDescribeServer} from '@atproto/api' +import * as EmailValidator from 'email-validator' +import {BskyAgent} from '@atproto/api' +import {useAnalytics} from 'lib/analytics/analytics' +import {Text} from '../../util/text/Text' +import {s} from 'lib/styles' +import {toNiceDomain} from 'lib/strings/url-helpers' +import {isNetworkError} from 'lib/strings/errors' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {cleanError} from 'lib/strings/errors' +import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {styles} from './styles' +import {useModalControls} from '#/state/modals' + +type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + +export const ForgotPasswordForm = ({ + error, + serviceUrl, + serviceDescription, + setError, + setServiceUrl, + onPressBack, + onEmailSent, +}: { + error: string + serviceUrl: string + serviceDescription: ServiceDescription | undefined + setError: (v: string) => void + setServiceUrl: (v: string) => void + onPressBack: () => void + onEmailSent: () => void +}) => { + const pal = usePalette('default') + const theme = useTheme() + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [email, setEmail] = useState<string>('') + const {screen} = useAnalytics() + const {_} = useLingui() + const {openModal} = useModalControls() + + useEffect(() => { + screen('Signin:ForgotPassword') + }, [screen]) + + const onPressSelectService = () => { + openModal({ + name: 'server-input', + initialService: serviceUrl, + onSelect: setServiceUrl, + }) + } + + const onPressNext = async () => { + if (!EmailValidator.validate(email)) { + return setError('Your email appears to be invalid.') + } + + setError('') + setIsProcessing(true) + + try { + const agent = new BskyAgent({service: serviceUrl}) + await agent.com.atproto.server.requestPasswordReset({email}) + onEmailSent() + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to request password reset', {error: e}) + setIsProcessing(false) + if (isNetworkError(e)) { + setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + } else { + setError(cleanError(errMsg)) + } + } + } + + return ( + <> + <View> + <Text type="title-lg" style={[pal.text, styles.screenTitle]}> + <Trans>Reset password</Trans> + </Text> + <Text type="md" style={[pal.text, styles.instructions]}> + <Trans> + Enter the email you used to create your account. We'll send you a + "reset code" so you can set a new password. + </Trans> + </Text> + <View + testID="forgotPasswordView" + style={[pal.borderDark, pal.view, styles.group]}> + <TouchableOpacity + testID="forgotPasswordSelectServiceButton" + style={[pal.borderDark, styles.groupContent, styles.noTopBorder]} + onPress={onPressSelectService} + accessibilityRole="button" + accessibilityLabel={_(msg`Hosting provider`)} + accessibilityHint="Sets hosting provider for password reset"> + <FontAwesomeIcon + icon="globe" + style={[pal.textLight, styles.groupContentIcon]} + /> + <Text style={[pal.text, styles.textInput]} numberOfLines={1}> + {toNiceDomain(serviceUrl)} + </Text> + <View style={[pal.btn, styles.textBtnFakeInnerBtn]}> + <FontAwesomeIcon + icon="pen" + size={12} + style={pal.text as FontAwesomeIconStyle} + /> + </View> + </TouchableOpacity> + <View style={[pal.borderDark, styles.groupContent]}> + <FontAwesomeIcon + icon="envelope" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="forgotPasswordEmail" + style={[pal.text, styles.textInput]} + placeholder="Email address" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoFocus + autoCorrect={false} + keyboardAppearance={theme.colorScheme} + value={email} + onChangeText={setEmail} + editable={!isProcessing} + accessibilityLabel={_(msg`Email`)} + accessibilityHint="Sets email for password reset" + /> + </View> + </View> + {error ? ( + <View style={styles.error}> + <View style={styles.errorIcon}> + <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> + </View> + <View style={s.flex1}> + <Text style={[s.white, s.bold]}>{error}</Text> + </View> + </View> + ) : undefined} + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> + <Text type="xl" style={[pal.link, s.pl5]}> + <Trans>Back</Trans> + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {!serviceDescription || isProcessing ? ( + <ActivityIndicator /> + ) : !email ? ( + <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}> + <Trans>Next</Trans> + </Text> + ) : ( + <TouchableOpacity + testID="newPasswordButton" + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel={_(msg`Go to next`)} + accessibilityHint="Navigates to the next screen"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + <Trans>Next</Trans> + </Text> + </TouchableOpacity> + )} + {!serviceDescription || isProcessing ? ( + <Text type="xl" style={[pal.textLight, s.pl10]}> + <Trans>Processing...</Trans> + </Text> + ) : undefined} + </View> + </View> + </> + ) +} diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx index acc05b6ca..67d0afdf1 100644 --- a/src/view/com/auth/login/Login.tsx +++ b/src/view/com/auth/login/Login.tsx @@ -1,36 +1,19 @@ -import React, {useState, useEffect, useRef} from 'react' -import { - ActivityIndicator, - Keyboard, - KeyboardAvoidingView, - ScrollView, - StyleSheet, - TextInput, - TouchableOpacity, - View, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import * as EmailValidator from 'email-validator' -import {BskyAgent} from '@atproto/api' +import React, {useState, useEffect} from 'react' +import {KeyboardAvoidingView} from 'react-native' import {useAnalytics} from 'lib/analytics/analytics' -import {Text} from '../../util/text/Text' -import {UserAvatar} from '../../util/UserAvatar' import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' -import {s, colors} from 'lib/styles' -import {createFullHandle} from 'lib/strings/handles' -import {toNiceDomain} from 'lib/strings/url-helpers' -import {useStores, RootStoreModel, DEFAULT_SERVICE} from 'state/index' -import {ServiceDescription} from 'state/models/session' -import {AccountData} from 'state/models/session' -import {isNetworkError} from 'lib/strings/errors' +import {DEFAULT_SERVICE} from '#/lib/constants' import {usePalette} from 'lib/hooks/usePalette' -import {useTheme} from 'lib/ThemeContext' -import {cleanError} from 'lib/strings/errors' -import {isWeb} from 'platform/detection' import {logger} from '#/logger' +import {ChooseAccountForm} from './ChooseAccountForm' +import {LoginForm} from './LoginForm' +import {ForgotPasswordForm} from './ForgotPasswordForm' +import {SetNewPasswordForm} from './SetNewPasswordForm' +import {PasswordUpdatedForm} from './PasswordUpdatedForm' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {useSession, SessionAccount} from '#/state/session' +import {useServiceQuery} from '#/state/queries/service' enum Forms { Login, @@ -42,20 +25,22 @@ enum Forms { export const Login = ({onPressBack}: {onPressBack: () => void}) => { const pal = usePalette('default') - const store = useStores() + const {accounts} = useSession() const {track} = useAnalytics() + const {_} = useLingui() const [error, setError] = useState<string>('') - const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({}) const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE) - const [serviceDescription, setServiceDescription] = useState< - ServiceDescription | undefined - >(undefined) const [initialHandle, setInitialHandle] = useState<string>('') const [currentForm, setCurrentForm] = useState<Forms>( - store.session.hasAccounts ? Forms.ChooseAccount : Forms.Login, + accounts.length ? Forms.ChooseAccount : Forms.Login, ) + const { + data: serviceDescription, + error: serviceError, + refetch: refetchService, + } = useServiceQuery(serviceUrl) - const onSelectAccount = (account?: AccountData) => { + const onSelectAccount = (account?: SessionAccount) => { if (account?.service) { setServiceUrl(account.service) } @@ -69,33 +54,21 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { } useEffect(() => { - let aborted = false - setError('') - store.session.describeService(serviceUrl).then( - desc => { - if (aborted) { - return - } - setServiceDescription(desc) - }, - err => { - if (aborted) { - return - } - logger.warn(`Failed to fetch service description for ${serviceUrl}`, { - error: err, - }) - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - }, - ) - return () => { - aborted = true + if (serviceError) { + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + logger.warn(`Failed to fetch service description for ${serviceUrl}`, { + error: String(serviceError), + }) + } else { + setError('') } - }, [store.session, serviceUrl, retryDescribeTrigger]) + }, [serviceError, serviceUrl, _]) - const onPressRetryConnect = () => setRetryDescribeTrigger({}) + const onPressRetryConnect = () => refetchService() const onPressForgotPassword = () => { track('Signin:PressedForgotPassword') setCurrentForm(Forms.ForgotPassword) @@ -106,10 +79,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { {currentForm === Forms.Login ? ( <LoggedOutLayout leadin="" - title="Sign in" - description="Enter your username and password"> + title={_(msg`Sign in`)} + description={_(msg`Enter your username and password`)}> <LoginForm - store={store} error={error} serviceUrl={serviceUrl} serviceDescription={serviceDescription} @@ -125,10 +97,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { {currentForm === Forms.ChooseAccount ? ( <LoggedOutLayout leadin="" - title="Sign in as..." - description="Select from an existing account"> + title={_(msg`Sign in as...`)} + description={_(msg`Select from an existing account`)}> <ChooseAccountForm - store={store} onSelectAccount={onSelectAccount} onPressBack={onPressBack} /> @@ -137,10 +108,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { {currentForm === Forms.ForgotPassword ? ( <LoggedOutLayout leadin="" - title="Forgot Password" - description="Let's get your password reset!"> + title={_(msg`Forgot Password`)} + description={_(msg`Let's get your password reset!`)}> <ForgotPasswordForm - store={store} error={error} serviceUrl={serviceUrl} serviceDescription={serviceDescription} @@ -154,10 +124,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { {currentForm === Forms.SetNewPassword ? ( <LoggedOutLayout leadin="" - title="Forgot Password" - description="Let's get your password reset!"> + title={_(msg`Forgot Password`)} + description={_(msg`Let's get your password reset!`)}> <SetNewPasswordForm - store={store} error={error} serviceUrl={serviceUrl} setError={setError} @@ -167,834 +136,13 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { </LoggedOutLayout> ) : undefined} {currentForm === Forms.PasswordUpdated ? ( - <PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} /> + <LoggedOutLayout + leadin="" + title={_(msg`Password updated`)} + description={_(msg`You can now sign in with your new password.`)}> + <PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} /> + </LoggedOutLayout> ) : undefined} </KeyboardAvoidingView> ) } - -const ChooseAccountForm = ({ - store, - onSelectAccount, - onPressBack, -}: { - store: RootStoreModel - onSelectAccount: (account?: AccountData) => void - onPressBack: () => void -}) => { - const {track, screen} = useAnalytics() - const pal = usePalette('default') - const [isProcessing, setIsProcessing] = React.useState(false) - - React.useEffect(() => { - screen('Choose Account') - }, [screen]) - - const onTryAccount = async (account: AccountData) => { - if (account.accessJwt && account.refreshJwt) { - setIsProcessing(true) - if (await store.session.resumeSession(account)) { - track('Sign In', {resumedSession: true}) - setIsProcessing(false) - return - } - setIsProcessing(false) - } - onSelectAccount(account) - } - - return ( - <ScrollView testID="chooseAccountForm" style={styles.maxHeight}> - <Text - type="2xl-medium" - style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}> - Sign in as... - </Text> - {store.session.accounts.map(account => ( - <TouchableOpacity - testID={`chooseAccountBtn-${account.handle}`} - key={account.did} - style={[pal.view, pal.border, styles.account]} - onPress={() => onTryAccount(account)} - accessibilityRole="button" - accessibilityLabel={`Sign in as ${account.handle}`} - accessibilityHint="Double tap to sign in"> - <View - style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <View style={s.p10}> - <UserAvatar avatar={account.aviUrl} size={30} /> - </View> - <Text style={styles.accountText}> - <Text type="lg-bold" style={pal.text}> - {account.displayName || account.handle}{' '} - </Text> - <Text type="lg" style={[pal.textLight]}> - {account.handle} - </Text> - </Text> - <FontAwesomeIcon - icon="angle-right" - size={16} - style={[pal.text, s.mr10]} - /> - </View> - </TouchableOpacity> - ))} - <TouchableOpacity - testID="chooseNewAccountBtn" - style={[pal.view, pal.border, styles.account, styles.accountLast]} - onPress={() => onSelectAccount(undefined)} - accessibilityRole="button" - accessibilityLabel="Login to account that is not listed" - accessibilityHint=""> - <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <Text style={[styles.accountText, styles.accountTextOther]}> - <Text type="lg" style={pal.text}> - Other account - </Text> - </Text> - <FontAwesomeIcon - icon="angle-right" - size={16} - style={[pal.text, s.mr10]} - /> - </View> - </TouchableOpacity> - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> - <Text type="xl" style={[pal.link, s.pl5]}> - Back - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {isProcessing && <ActivityIndicator />} - </View> - </ScrollView> - ) -} - -const LoginForm = ({ - store, - error, - serviceUrl, - serviceDescription, - initialHandle, - setError, - setServiceUrl, - onPressRetryConnect, - onPressBack, - onPressForgotPassword, -}: { - store: RootStoreModel - error: string - serviceUrl: string - serviceDescription: ServiceDescription | undefined - initialHandle: string - setError: (v: string) => void - setServiceUrl: (v: string) => void - onPressRetryConnect: () => void - onPressBack: () => void - onPressForgotPassword: () => void -}) => { - const {track} = useAnalytics() - const pal = usePalette('default') - const theme = useTheme() - const [isProcessing, setIsProcessing] = useState<boolean>(false) - const [identifier, setIdentifier] = useState<string>(initialHandle) - const [password, setPassword] = useState<string>('') - const passwordInputRef = useRef<TextInput>(null) - - const onPressSelectService = () => { - store.shell.openModal({ - name: 'server-input', - initialService: serviceUrl, - onSelect: setServiceUrl, - }) - Keyboard.dismiss() - track('Signin:PressedSelectService') - } - - const onPressNext = async () => { - Keyboard.dismiss() - setError('') - setIsProcessing(true) - - try { - // try to guess the handle if the user just gave their own username - let fullIdent = identifier - if ( - !identifier.includes('@') && // not an email - !identifier.includes('.') && // not a domain - serviceDescription && - serviceDescription.availableUserDomains.length > 0 - ) { - let matched = false - for (const domain of serviceDescription.availableUserDomains) { - if (fullIdent.endsWith(domain)) { - matched = true - } - } - if (!matched) { - fullIdent = createFullHandle( - identifier, - serviceDescription.availableUserDomains[0], - ) - } - } - - await store.session.login({ - service: serviceUrl, - identifier: fullIdent, - password, - }) - } catch (e: any) { - const errMsg = e.toString() - logger.warn('Failed to login', {error: e}) - setIsProcessing(false) - if (errMsg.includes('Authentication Required')) { - setError('Invalid username or password') - } else if (isNetworkError(e)) { - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - } else { - setError(cleanError(errMsg)) - } - } finally { - track('Sign In', {resumedSession: false}) - } - } - - const isReady = !!serviceDescription && !!identifier && !!password - return ( - <View testID="loginForm"> - <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> - Sign into - </Text> - <View style={[pal.borderDark, styles.group]}> - <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <FontAwesomeIcon - icon="globe" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TouchableOpacity - testID="loginSelectServiceButton" - style={styles.textBtn} - onPress={onPressSelectService} - accessibilityRole="button" - accessibilityLabel="Select service" - accessibilityHint="Sets server for the Bluesky client"> - <Text type="xl" style={[pal.text, styles.textBtnLabel]}> - {toNiceDomain(serviceUrl)} - </Text> - <View style={[pal.btn, styles.textBtnFakeInnerBtn]}> - <FontAwesomeIcon - icon="pen" - size={12} - style={pal.textLight as FontAwesomeIconStyle} - /> - </View> - </TouchableOpacity> - </View> - </View> - <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> - Account - </Text> - <View style={[pal.borderDark, styles.group]}> - <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <FontAwesomeIcon - icon="at" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="loginUsernameInput" - style={[pal.text, styles.textInput]} - placeholder="Username or email address" - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoFocus - autoCorrect={false} - autoComplete="username" - returnKeyType="next" - onSubmitEditing={() => { - passwordInputRef.current?.focus() - }} - blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field - keyboardAppearance={theme.colorScheme} - value={identifier} - onChangeText={str => - setIdentifier((str || '').toLowerCase().trim()) - } - editable={!isProcessing} - accessibilityLabel="Username or email address" - accessibilityHint="Input the username or email address you used at signup" - /> - </View> - <View style={[pal.borderDark, styles.groupContent]}> - <FontAwesomeIcon - icon="lock" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="loginPasswordInput" - ref={passwordInputRef} - style={[pal.text, styles.textInput]} - placeholder="Password" - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoCorrect={false} - autoComplete="password" - returnKeyType="done" - enablesReturnKeyAutomatically={true} - keyboardAppearance={theme.colorScheme} - secureTextEntry={true} - textContentType="password" - clearButtonMode="while-editing" - value={password} - onChangeText={setPassword} - onSubmitEditing={onPressNext} - blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing - editable={!isProcessing} - accessibilityLabel="Password" - accessibilityHint={ - identifier === '' - ? 'Input your password' - : `Input the password tied to ${identifier}` - } - /> - <TouchableOpacity - testID="forgotPasswordButton" - style={styles.textInputInnerBtn} - onPress={onPressForgotPassword} - accessibilityRole="button" - accessibilityLabel="Forgot password" - accessibilityHint="Opens password reset form"> - <Text style={pal.link}>Forgot</Text> - </TouchableOpacity> - </View> - </View> - {error ? ( - <View style={styles.error}> - <View style={styles.errorIcon}> - <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> - </View> - <View style={s.flex1}> - <Text style={[s.white, s.bold]}>{error}</Text> - </View> - </View> - ) : undefined} - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> - <Text type="xl" style={[pal.link, s.pl5]}> - Back - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {!serviceDescription && error ? ( - <TouchableOpacity - testID="loginRetryButton" - onPress={onPressRetryConnect} - accessibilityRole="button" - accessibilityLabel="Retry" - accessibilityHint="Retries login"> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Retry - </Text> - </TouchableOpacity> - ) : !serviceDescription ? ( - <> - <ActivityIndicator /> - <Text type="xl" style={[pal.textLight, s.pl10]}> - Connecting... - </Text> - </> - ) : isProcessing ? ( - <ActivityIndicator /> - ) : isReady ? ( - <TouchableOpacity - testID="loginNextButton" - onPress={onPressNext} - accessibilityRole="button" - accessibilityLabel="Go to next" - accessibilityHint="Navigates to the next screen"> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Next - </Text> - </TouchableOpacity> - ) : undefined} - </View> - </View> - ) -} - -const ForgotPasswordForm = ({ - store, - error, - serviceUrl, - serviceDescription, - setError, - setServiceUrl, - onPressBack, - onEmailSent, -}: { - store: RootStoreModel - error: string - serviceUrl: string - serviceDescription: ServiceDescription | undefined - setError: (v: string) => void - setServiceUrl: (v: string) => void - onPressBack: () => void - onEmailSent: () => void -}) => { - const pal = usePalette('default') - const theme = useTheme() - const [isProcessing, setIsProcessing] = useState<boolean>(false) - const [email, setEmail] = useState<string>('') - const {screen} = useAnalytics() - - useEffect(() => { - screen('Signin:ForgotPassword') - }, [screen]) - - const onPressSelectService = () => { - store.shell.openModal({ - name: 'server-input', - initialService: serviceUrl, - onSelect: setServiceUrl, - }) - } - - const onPressNext = async () => { - if (!EmailValidator.validate(email)) { - return setError('Your email appears to be invalid.') - } - - setError('') - setIsProcessing(true) - - try { - const agent = new BskyAgent({service: serviceUrl}) - await agent.com.atproto.server.requestPasswordReset({email}) - onEmailSent() - } catch (e: any) { - const errMsg = e.toString() - logger.warn('Failed to request password reset', {error: e}) - setIsProcessing(false) - if (isNetworkError(e)) { - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - } else { - setError(cleanError(errMsg)) - } - } - } - - return ( - <> - <View> - <Text type="title-lg" style={[pal.text, styles.screenTitle]}> - Reset password - </Text> - <Text type="md" style={[pal.text, styles.instructions]}> - Enter the email you used to create your account. We'll send you a - "reset code" so you can set a new password. - </Text> - <View - testID="forgotPasswordView" - style={[pal.borderDark, pal.view, styles.group]}> - <TouchableOpacity - testID="forgotPasswordSelectServiceButton" - style={[pal.borderDark, styles.groupContent, styles.noTopBorder]} - onPress={onPressSelectService} - accessibilityRole="button" - accessibilityLabel="Hosting provider" - accessibilityHint="Sets hosting provider for password reset"> - <FontAwesomeIcon - icon="globe" - style={[pal.textLight, styles.groupContentIcon]} - /> - <Text style={[pal.text, styles.textInput]} numberOfLines={1}> - {toNiceDomain(serviceUrl)} - </Text> - <View style={[pal.btn, styles.textBtnFakeInnerBtn]}> - <FontAwesomeIcon - icon="pen" - size={12} - style={pal.text as FontAwesomeIconStyle} - /> - </View> - </TouchableOpacity> - <View style={[pal.borderDark, styles.groupContent]}> - <FontAwesomeIcon - icon="envelope" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="forgotPasswordEmail" - style={[pal.text, styles.textInput]} - placeholder="Email address" - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoFocus - autoCorrect={false} - keyboardAppearance={theme.colorScheme} - value={email} - onChangeText={setEmail} - editable={!isProcessing} - accessibilityLabel="Email" - accessibilityHint="Sets email for password reset" - /> - </View> - </View> - {error ? ( - <View style={styles.error}> - <View style={styles.errorIcon}> - <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> - </View> - <View style={s.flex1}> - <Text style={[s.white, s.bold]}>{error}</Text> - </View> - </View> - ) : undefined} - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> - <Text type="xl" style={[pal.link, s.pl5]}> - Back - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {!serviceDescription || isProcessing ? ( - <ActivityIndicator /> - ) : !email ? ( - <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}> - Next - </Text> - ) : ( - <TouchableOpacity - testID="newPasswordButton" - onPress={onPressNext} - accessibilityRole="button" - accessibilityLabel="Go to next" - accessibilityHint="Navigates to the next screen"> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Next - </Text> - </TouchableOpacity> - )} - {!serviceDescription || isProcessing ? ( - <Text type="xl" style={[pal.textLight, s.pl10]}> - Processing... - </Text> - ) : undefined} - </View> - </View> - </> - ) -} - -const SetNewPasswordForm = ({ - error, - serviceUrl, - setError, - onPressBack, - onPasswordSet, -}: { - store: RootStoreModel - error: string - serviceUrl: string - setError: (v: string) => void - onPressBack: () => void - onPasswordSet: () => void -}) => { - const pal = usePalette('default') - const theme = useTheme() - const {screen} = useAnalytics() - - useEffect(() => { - screen('Signin:SetNewPasswordForm') - }, [screen]) - - const [isProcessing, setIsProcessing] = useState<boolean>(false) - const [resetCode, setResetCode] = useState<string>('') - const [password, setPassword] = useState<string>('') - - const onPressNext = async () => { - setError('') - setIsProcessing(true) - - try { - const agent = new BskyAgent({service: serviceUrl}) - const token = resetCode.replace(/\s/g, '') - await agent.com.atproto.server.resetPassword({ - token, - password, - }) - onPasswordSet() - } catch (e: any) { - const errMsg = e.toString() - logger.warn('Failed to set new password', {error: e}) - setIsProcessing(false) - if (isNetworkError(e)) { - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - } else { - setError(cleanError(errMsg)) - } - } - } - - return ( - <> - <View> - <Text type="title-lg" style={[pal.text, styles.screenTitle]}> - Set new password - </Text> - <Text type="lg" style={[pal.text, styles.instructions]}> - You will receive an email with a "reset code." Enter that code here, - then enter your new password. - </Text> - <View - testID="newPasswordView" - style={[pal.view, pal.borderDark, styles.group]}> - <View - style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <FontAwesomeIcon - icon="ticket" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="resetCodeInput" - style={[pal.text, styles.textInput]} - placeholder="Reset code" - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoCorrect={false} - keyboardAppearance={theme.colorScheme} - autoFocus - value={resetCode} - onChangeText={setResetCode} - editable={!isProcessing} - accessible={true} - accessibilityLabel="Reset code" - accessibilityHint="Input code sent to your email for password reset" - /> - </View> - <View style={[pal.borderDark, styles.groupContent]}> - <FontAwesomeIcon - icon="lock" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="newPasswordInput" - style={[pal.text, styles.textInput]} - placeholder="New password" - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoCorrect={false} - keyboardAppearance={theme.colorScheme} - secureTextEntry - value={password} - onChangeText={setPassword} - editable={!isProcessing} - accessible={true} - accessibilityLabel="Password" - accessibilityHint="Input new password" - /> - </View> - </View> - {error ? ( - <View style={styles.error}> - <View style={styles.errorIcon}> - <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> - </View> - <View style={s.flex1}> - <Text style={[s.white, s.bold]}>{error}</Text> - </View> - </View> - ) : undefined} - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> - <Text type="xl" style={[pal.link, s.pl5]}> - Back - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {isProcessing ? ( - <ActivityIndicator /> - ) : !resetCode || !password ? ( - <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}> - Next - </Text> - ) : ( - <TouchableOpacity - testID="setNewPasswordButton" - onPress={onPressNext} - accessibilityRole="button" - accessibilityLabel="Go to next" - accessibilityHint="Navigates to the next screen"> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Next - </Text> - </TouchableOpacity> - )} - {isProcessing ? ( - <Text type="xl" style={[pal.textLight, s.pl10]}> - Updating... - </Text> - ) : undefined} - </View> - </View> - </> - ) -} - -const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => { - const {screen} = useAnalytics() - - useEffect(() => { - screen('Signin:PasswordUpdatedForm') - }, [screen]) - - const pal = usePalette('default') - return ( - <> - <View> - <Text type="title-lg" style={[pal.text, styles.screenTitle]}> - Password updated! - </Text> - <Text type="lg" style={[pal.text, styles.instructions]}> - You can now sign in with your new password. - </Text> - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <View style={s.flex1} /> - <TouchableOpacity - onPress={onPressNext} - accessibilityRole="button" - accessibilityLabel="Close alert" - accessibilityHint="Closes password update alert"> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Okay - </Text> - </TouchableOpacity> - </View> - </View> - </> - ) -} - -const styles = StyleSheet.create({ - screenTitle: { - marginBottom: 10, - marginHorizontal: 20, - }, - instructions: { - marginBottom: 20, - marginHorizontal: 20, - }, - group: { - borderWidth: 1, - borderRadius: 10, - marginBottom: 20, - marginHorizontal: 20, - }, - groupLabel: { - paddingHorizontal: 20, - paddingBottom: 5, - }, - groupContent: { - borderTopWidth: 1, - flexDirection: 'row', - alignItems: 'center', - }, - noTopBorder: { - borderTopWidth: 0, - }, - groupContentIcon: { - marginLeft: 10, - }, - account: { - borderTopWidth: 1, - paddingHorizontal: 20, - paddingVertical: 4, - }, - accountLast: { - borderBottomWidth: 1, - marginBottom: 20, - paddingVertical: 8, - }, - textInput: { - flex: 1, - width: '100%', - paddingVertical: 10, - paddingHorizontal: 12, - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '400', - borderRadius: 10, - }, - textInputInnerBtn: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 6, - paddingHorizontal: 8, - marginHorizontal: 6, - }, - textBtn: { - flexDirection: 'row', - flex: 1, - alignItems: 'center', - }, - textBtnLabel: { - flex: 1, - paddingVertical: 10, - paddingHorizontal: 12, - }, - textBtnFakeInnerBtn: { - flexDirection: 'row', - alignItems: 'center', - borderRadius: 6, - paddingVertical: 6, - paddingHorizontal: 8, - marginHorizontal: 6, - }, - accountText: { - flex: 1, - flexDirection: 'row', - alignItems: 'baseline', - paddingVertical: 10, - }, - accountTextOther: { - paddingLeft: 12, - }, - error: { - backgroundColor: colors.red4, - flexDirection: 'row', - alignItems: 'center', - marginTop: -5, - marginHorizontal: 20, - marginBottom: 15, - borderRadius: 8, - paddingHorizontal: 8, - paddingVertical: 8, - }, - errorIcon: { - borderWidth: 1, - borderColor: colors.white, - color: colors.white, - borderRadius: 30, - width: 16, - height: 16, - alignItems: 'center', - justifyContent: 'center', - marginRight: 5, - }, - dimmed: {opacity: 0.5}, - - maxHeight: { - // @ts-ignore web only -prf - maxHeight: isWeb ? '100vh' : undefined, - height: !isWeb ? '100%' : undefined, - }, -}) diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx new file mode 100644 index 000000000..365f2e253 --- /dev/null +++ b/src/view/com/auth/login/LoginForm.tsx @@ -0,0 +1,290 @@ +import React, {useState, useRef} from 'react' +import { + ActivityIndicator, + Keyboard, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {ComAtprotoServerDescribeServer} from '@atproto/api' +import {useAnalytics} from 'lib/analytics/analytics' +import {Text} from '../../util/text/Text' +import {s} from 'lib/styles' +import {createFullHandle} from 'lib/strings/handles' +import {toNiceDomain} from 'lib/strings/url-helpers' +import {isNetworkError} from 'lib/strings/errors' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {useSessionApi} from '#/state/session' +import {cleanError} from 'lib/strings/errors' +import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {styles} from './styles' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' + +type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + +export const LoginForm = ({ + error, + serviceUrl, + serviceDescription, + initialHandle, + setError, + setServiceUrl, + onPressRetryConnect, + onPressBack, + onPressForgotPassword, +}: { + error: string + serviceUrl: string + serviceDescription: ServiceDescription | undefined + initialHandle: string + setError: (v: string) => void + setServiceUrl: (v: string) => void + onPressRetryConnect: () => void + onPressBack: () => void + onPressForgotPassword: () => void +}) => { + const {track} = useAnalytics() + const pal = usePalette('default') + const theme = useTheme() + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [identifier, setIdentifier] = useState<string>(initialHandle) + const [password, setPassword] = useState<string>('') + const passwordInputRef = useRef<TextInput>(null) + const {_} = useLingui() + const {openModal} = useModalControls() + const {login} = useSessionApi() + + const onPressSelectService = () => { + openModal({ + name: 'server-input', + initialService: serviceUrl, + onSelect: setServiceUrl, + }) + Keyboard.dismiss() + track('Signin:PressedSelectService') + } + + const onPressNext = async () => { + Keyboard.dismiss() + setError('') + setIsProcessing(true) + + try { + // try to guess the handle if the user just gave their own username + let fullIdent = identifier + if ( + !identifier.includes('@') && // not an email + !identifier.includes('.') && // not a domain + serviceDescription && + serviceDescription.availableUserDomains.length > 0 + ) { + let matched = false + for (const domain of serviceDescription.availableUserDomains) { + if (fullIdent.endsWith(domain)) { + matched = true + } + } + if (!matched) { + fullIdent = createFullHandle( + identifier, + serviceDescription.availableUserDomains[0], + ) + } + } + + // TODO remove double login + await login({ + service: serviceUrl, + identifier: fullIdent, + password, + }) + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to login', {error: e}) + setIsProcessing(false) + if (errMsg.includes('Authentication Required')) { + setError(_(msg`Invalid username or password`)) + } else if (isNetworkError(e)) { + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + } else { + setError(cleanError(errMsg)) + } + } finally { + track('Sign In', {resumedSession: false}) + } + } + + const isReady = !!serviceDescription && !!identifier && !!password + return ( + <View testID="loginForm"> + <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> + <Trans>Sign into</Trans> + </Text> + <View style={[pal.borderDark, styles.group]}> + <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="globe" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TouchableOpacity + testID="loginSelectServiceButton" + style={styles.textBtn} + onPress={onPressSelectService} + accessibilityRole="button" + accessibilityLabel={_(msg`Select service`)} + accessibilityHint="Sets server for the Bluesky client"> + <Text type="xl" style={[pal.text, styles.textBtnLabel]}> + {toNiceDomain(serviceUrl)} + </Text> + <View style={[pal.btn, styles.textBtnFakeInnerBtn]}> + <FontAwesomeIcon + icon="pen" + size={12} + style={pal.textLight as FontAwesomeIconStyle} + /> + </View> + </TouchableOpacity> + </View> + </View> + <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> + <Trans>Account</Trans> + </Text> + <View style={[pal.borderDark, styles.group]}> + <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="at" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="loginUsernameInput" + style={[pal.text, styles.textInput]} + placeholder={_(msg`Username or email address`)} + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoFocus + autoCorrect={false} + autoComplete="username" + returnKeyType="next" + onSubmitEditing={() => { + passwordInputRef.current?.focus() + }} + blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field + keyboardAppearance={theme.colorScheme} + value={identifier} + onChangeText={str => + setIdentifier((str || '').toLowerCase().trim()) + } + editable={!isProcessing} + accessibilityLabel={_(msg`Username or email address`)} + accessibilityHint="Input the username or email address you used at signup" + /> + </View> + <View style={[pal.borderDark, styles.groupContent]}> + <FontAwesomeIcon + icon="lock" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="loginPasswordInput" + ref={passwordInputRef} + style={[pal.text, styles.textInput]} + placeholder="Password" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + autoComplete="password" + returnKeyType="done" + enablesReturnKeyAutomatically={true} + keyboardAppearance={theme.colorScheme} + secureTextEntry={true} + textContentType="password" + clearButtonMode="while-editing" + value={password} + onChangeText={setPassword} + onSubmitEditing={onPressNext} + blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing + editable={!isProcessing} + accessibilityLabel={_(msg`Password`)} + accessibilityHint={ + identifier === '' + ? 'Input your password' + : `Input the password tied to ${identifier}` + } + /> + <TouchableOpacity + testID="forgotPasswordButton" + style={styles.textInputInnerBtn} + onPress={onPressForgotPassword} + accessibilityRole="button" + accessibilityLabel={_(msg`Forgot password`)} + accessibilityHint="Opens password reset form"> + <Text style={pal.link}> + <Trans>Forgot</Trans> + </Text> + </TouchableOpacity> + </View> + </View> + {error ? ( + <View style={styles.error}> + <View style={styles.errorIcon}> + <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> + </View> + <View style={s.flex1}> + <Text style={[s.white, s.bold]}>{error}</Text> + </View> + </View> + ) : undefined} + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> + <Text type="xl" style={[pal.link, s.pl5]}> + <Trans>Back</Trans> + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {!serviceDescription && error ? ( + <TouchableOpacity + testID="loginRetryButton" + onPress={onPressRetryConnect} + accessibilityRole="button" + accessibilityLabel={_(msg`Retry`)} + accessibilityHint="Retries login"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + <Trans>Retry</Trans> + </Text> + </TouchableOpacity> + ) : !serviceDescription ? ( + <> + <ActivityIndicator /> + <Text type="xl" style={[pal.textLight, s.pl10]}> + <Trans>Connecting...</Trans> + </Text> + </> + ) : isProcessing ? ( + <ActivityIndicator /> + ) : isReady ? ( + <TouchableOpacity + testID="loginNextButton" + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel={_(msg`Go to next`)} + accessibilityHint="Navigates to the next screen"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + <Trans>Next</Trans> + </Text> + </TouchableOpacity> + ) : undefined} + </View> + </View> + ) +} diff --git a/src/view/com/auth/login/PasswordUpdatedForm.tsx b/src/view/com/auth/login/PasswordUpdatedForm.tsx new file mode 100644 index 000000000..1e07588a9 --- /dev/null +++ b/src/view/com/auth/login/PasswordUpdatedForm.tsx @@ -0,0 +1,48 @@ +import React, {useEffect} from 'react' +import {TouchableOpacity, View} from 'react-native' +import {useAnalytics} from 'lib/analytics/analytics' +import {Text} from '../../util/text/Text' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {styles} from './styles' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +export const PasswordUpdatedForm = ({ + onPressNext, +}: { + onPressNext: () => void +}) => { + const {screen} = useAnalytics() + const pal = usePalette('default') + const {_} = useLingui() + + useEffect(() => { + screen('Signin:PasswordUpdatedForm') + }, [screen]) + + return ( + <> + <View> + <Text type="title-lg" style={[pal.text, styles.screenTitle]}> + <Trans>Password updated!</Trans> + </Text> + <Text type="lg" style={[pal.text, styles.instructions]}> + <Trans>You can now sign in with your new password.</Trans> + </Text> + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <View style={s.flex1} /> + <TouchableOpacity + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel={_(msg`Close alert`)} + accessibilityHint="Closes password update alert"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + <Trans>Okay</Trans> + </Text> + </TouchableOpacity> + </View> + </View> + </> + ) +} diff --git a/src/view/com/auth/login/SetNewPasswordForm.tsx b/src/view/com/auth/login/SetNewPasswordForm.tsx new file mode 100644 index 000000000..2bb614df2 --- /dev/null +++ b/src/view/com/auth/login/SetNewPasswordForm.tsx @@ -0,0 +1,179 @@ +import React, {useState, useEffect} from 'react' +import { + ActivityIndicator, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {BskyAgent} from '@atproto/api' +import {useAnalytics} from 'lib/analytics/analytics' +import {Text} from '../../util/text/Text' +import {s} from 'lib/styles' +import {isNetworkError} from 'lib/strings/errors' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {cleanError} from 'lib/strings/errors' +import {logger} from '#/logger' +import {styles} from './styles' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +export const SetNewPasswordForm = ({ + error, + serviceUrl, + setError, + onPressBack, + onPasswordSet, +}: { + error: string + serviceUrl: string + setError: (v: string) => void + onPressBack: () => void + onPasswordSet: () => void +}) => { + const pal = usePalette('default') + const theme = useTheme() + const {screen} = useAnalytics() + const {_} = useLingui() + + useEffect(() => { + screen('Signin:SetNewPasswordForm') + }, [screen]) + + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [resetCode, setResetCode] = useState<string>('') + const [password, setPassword] = useState<string>('') + + const onPressNext = async () => { + setError('') + setIsProcessing(true) + + try { + const agent = new BskyAgent({service: serviceUrl}) + const token = resetCode.replace(/\s/g, '') + await agent.com.atproto.server.resetPassword({ + token, + password, + }) + onPasswordSet() + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to set new password', {error: e}) + setIsProcessing(false) + if (isNetworkError(e)) { + setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + } else { + setError(cleanError(errMsg)) + } + } + } + + return ( + <> + <View> + <Text type="title-lg" style={[pal.text, styles.screenTitle]}> + <Trans>Set new password</Trans> + </Text> + <Text type="lg" style={[pal.text, styles.instructions]}> + <Trans> + You will receive an email with a "reset code." Enter that code here, + then enter your new password. + </Trans> + </Text> + <View + testID="newPasswordView" + style={[pal.view, pal.borderDark, styles.group]}> + <View + style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="ticket" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="resetCodeInput" + style={[pal.text, styles.textInput]} + placeholder="Reset code" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + keyboardAppearance={theme.colorScheme} + autoFocus + value={resetCode} + onChangeText={setResetCode} + editable={!isProcessing} + accessible={true} + accessibilityLabel={_(msg`Reset code`)} + accessibilityHint="Input code sent to your email for password reset" + /> + </View> + <View style={[pal.borderDark, styles.groupContent]}> + <FontAwesomeIcon + icon="lock" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="newPasswordInput" + style={[pal.text, styles.textInput]} + placeholder="New password" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + keyboardAppearance={theme.colorScheme} + secureTextEntry + value={password} + onChangeText={setPassword} + editable={!isProcessing} + accessible={true} + accessibilityLabel={_(msg`Password`)} + accessibilityHint="Input new password" + /> + </View> + </View> + {error ? ( + <View style={styles.error}> + <View style={styles.errorIcon}> + <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> + </View> + <View style={s.flex1}> + <Text style={[s.white, s.bold]}>{error}</Text> + </View> + </View> + ) : undefined} + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> + <Text type="xl" style={[pal.link, s.pl5]}> + <Trans>Back</Trans> + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {isProcessing ? ( + <ActivityIndicator /> + ) : !resetCode || !password ? ( + <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}> + <Trans>Next</Trans> + </Text> + ) : ( + <TouchableOpacity + testID="setNewPasswordButton" + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel={_(msg`Go to next`)} + accessibilityHint="Navigates to the next screen"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + <Trans>Next</Trans> + </Text> + </TouchableOpacity> + )} + {isProcessing ? ( + <Text type="xl" style={[pal.textLight, s.pl10]}> + <Trans>Updating...</Trans> + </Text> + ) : undefined} + </View> + </View> + </> + ) +} diff --git a/src/view/com/auth/login/styles.ts b/src/view/com/auth/login/styles.ts new file mode 100644 index 000000000..9dccc2803 --- /dev/null +++ b/src/view/com/auth/login/styles.ts @@ -0,0 +1,118 @@ +import {StyleSheet} from 'react-native' +import {colors} from 'lib/styles' +import {isWeb} from '#/platform/detection' + +export const styles = StyleSheet.create({ + screenTitle: { + marginBottom: 10, + marginHorizontal: 20, + }, + instructions: { + marginBottom: 20, + marginHorizontal: 20, + }, + group: { + borderWidth: 1, + borderRadius: 10, + marginBottom: 20, + marginHorizontal: 20, + }, + groupLabel: { + paddingHorizontal: 20, + paddingBottom: 5, + }, + groupContent: { + borderTopWidth: 1, + flexDirection: 'row', + alignItems: 'center', + }, + noTopBorder: { + borderTopWidth: 0, + }, + groupContentIcon: { + marginLeft: 10, + }, + account: { + borderTopWidth: 1, + paddingHorizontal: 20, + paddingVertical: 4, + }, + accountLast: { + borderBottomWidth: 1, + marginBottom: 20, + paddingVertical: 8, + }, + textInput: { + flex: 1, + width: '100%', + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '400', + borderRadius: 10, + }, + textInputInnerBtn: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 6, + paddingHorizontal: 8, + marginHorizontal: 6, + }, + textBtn: { + flexDirection: 'row', + flex: 1, + alignItems: 'center', + }, + textBtnLabel: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 12, + }, + textBtnFakeInnerBtn: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 8, + marginHorizontal: 6, + }, + accountText: { + flex: 1, + flexDirection: 'row', + alignItems: 'baseline', + paddingVertical: 10, + }, + accountTextOther: { + paddingLeft: 12, + }, + error: { + backgroundColor: colors.red4, + flexDirection: 'row', + alignItems: 'center', + marginTop: -5, + marginHorizontal: 20, + marginBottom: 15, + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 8, + }, + errorIcon: { + borderWidth: 1, + borderColor: colors.white, + color: colors.white, + borderRadius: 30, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, + dimmed: {opacity: 0.5}, + + maxHeight: { + // @ts-ignore web only -prf + maxHeight: isWeb ? '100vh' : undefined, + height: !isWeb ? '100%' : undefined, + }, +}) diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx index 400b836d0..d3318bffd 100644 --- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx @@ -1,6 +1,5 @@ import React from 'react' import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints' import {Text} from 'view/com/util/text/Text' @@ -10,76 +9,55 @@ import {Button} from 'view/com/util/forms/Button' import {RecommendedFeedsItem} from './RecommendedFeedsItem' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {usePalette} from 'lib/hooks/usePalette' -import {useQuery} from '@tanstack/react-query' -import {useStores} from 'state/index' -import {FeedSourceModel} from 'state/models/content/feed-source' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSuggestedFeedsQuery} from '#/state/queries/suggested-feeds' type Props = { next: () => void } -export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ - next, -}: Props) { - const store = useStores() +export function RecommendedFeeds({next}: Props) { const pal = usePalette('default') + const {_} = useLingui() const {isTabletOrMobile} = useWebMediaQueries() - const {isLoading, data: recommendedFeeds} = useQuery({ - staleTime: Infinity, // fixed list rn, never refetch - queryKey: ['onboarding', 'recommended_feeds'], - async queryFn() { - try { - const { - data: {feeds}, - success, - } = await store.agent.app.bsky.feed.getSuggestedFeeds() + const {isLoading, data} = useSuggestedFeedsQuery() - if (!success) { - return [] - } - - return (feeds.length ? feeds : []).map(feed => { - const model = new FeedSourceModel(store, feed.uri) - model.hydrateFeedGenerator(feed) - return model - }) - } catch (e) { - return [] - } - }, - }) - - const hasFeeds = recommendedFeeds && recommendedFeeds.length + const hasFeeds = data && data.pages[0].feeds.length const title = ( <> - <Text - style={[ - pal.textLight, - tdStyles.title1, - isTabletOrMobile && tdStyles.title1Small, - ]}> - Choose your - </Text> - <Text - style={[ - pal.link, - tdStyles.title2, - isTabletOrMobile && tdStyles.title2Small, - ]}> - Recommended - </Text> - <Text - style={[ - pal.link, - tdStyles.title2, - isTabletOrMobile && tdStyles.title2Small, - ]}> - Feeds - </Text> + <Trans> + <Text + style={[ + pal.textLight, + tdStyles.title1, + isTabletOrMobile && tdStyles.title1Small, + ]}> + Choose your + </Text> + <Text + style={[ + pal.link, + tdStyles.title2, + isTabletOrMobile && tdStyles.title2Small, + ]}> + Recommended + </Text> + <Text + style={[ + pal.link, + tdStyles.title2, + isTabletOrMobile && tdStyles.title2Small, + ]}> + Feeds + </Text> + </Trans> <Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}> - Feeds are created by users to curate content. Choose some feeds that you - find interesting. + <Trans> + Feeds are created by users to curate content. Choose some feeds that + you find interesting. + </Trans> </Text> <View style={{ @@ -98,7 +76,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ <Text type="2xl-medium" style={{color: '#fff', position: 'relative', top: -1}}> - Next + <Trans>Next</Trans> </Text> <FontAwesomeIcon icon="angle-right" color="#fff" size={14} /> </View> @@ -118,7 +96,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ contentStyle={{paddingHorizontal: 0}}> {hasFeeds ? ( <FlatList - data={recommendedFeeds} + data={data.pages[0].feeds} renderItem={({item}) => <RecommendedFeedsItem item={item} />} keyExtractor={item => item.uri} style={{flex: 1}} @@ -128,25 +106,27 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ <ActivityIndicator size="large" /> </View> ) : ( - <ErrorMessage message="Failed to load recommended feeds" /> + <ErrorMessage message={_(msg`Failed to load recommended feeds`)} /> )} </TitleColumnLayout> </TabletOrDesktop> <Mobile> <View style={[mStyles.container]} testID="recommendedFeedsOnboarding"> <ViewHeader - title="Recommended Feeds" + title={_(msg`Recommended Feeds`)} showBackButton={false} showOnDesktop /> <Text type="lg-medium" style={[pal.text, mStyles.header]}> - Check out some recommended feeds. Tap + to add them to your list of - pinned feeds. + <Trans> + Check out some recommended feeds. Tap + to add them to your list + of pinned feeds. + </Trans> </Text> {hasFeeds ? ( <FlatList - data={recommendedFeeds} + data={data.pages[0].feeds} renderItem={({item}) => <RecommendedFeedsItem item={item} />} keyExtractor={item => item.uri} style={{flex: 1}} @@ -157,13 +137,15 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ </View> ) : ( <View style={{flex: 1}}> - <ErrorMessage message="Failed to load recommended feeds" /> + <ErrorMessage + message={_(msg`Failed to load recommended feeds`)} + /> </View> )} <Button onPress={next} - label="Continue" + label={_(msg`Continue`)} testID="continueBtn" style={mStyles.button} labelStyle={mStyles.buttonText} @@ -172,7 +154,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ </Mobile> </> ) -}) +} const tdStyles = StyleSheet.create({ container: { diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx index bee23c953..7417e5b06 100644 --- a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx @@ -1,7 +1,7 @@ import React from 'react' import {View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {AppBskyFeedDefs, RichText as BskRichText} from '@atproto/api' import {Text} from 'view/com/util/text/Text' import {RichText} from 'view/com/util/text/RichText' import {Button} from 'view/com/util/forms/Button' @@ -11,33 +11,58 @@ import {HeartIcon} from 'lib/icons' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {sanitizeHandle} from 'lib/strings/handles' -import {FeedSourceModel} from 'state/models/content/feed-source' +import { + usePreferencesQuery, + usePinFeedMutation, + useRemoveFeedMutation, +} from '#/state/queries/preferences' +import {logger} from '#/logger' -export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ +export function RecommendedFeedsItem({ item, }: { - item: FeedSourceModel + item: AppBskyFeedDefs.GeneratorView }) { const {isMobile} = useWebMediaQueries() const pal = usePalette('default') - if (!item) return null + const {data: preferences} = usePreferencesQuery() + const { + mutateAsync: pinFeed, + variables: pinnedFeed, + reset: resetPinFeed, + } = usePinFeedMutation() + const { + mutateAsync: removeFeed, + variables: removedFeed, + reset: resetRemoveFeed, + } = useRemoveFeedMutation() + + if (!item || !preferences) return null + + const isPinned = + !removedFeed?.uri && + (pinnedFeed?.uri || preferences.feeds.saved.includes(item.uri)) + const onToggle = async () => { - if (item.isSaved) { + if (isPinned) { try { - await item.unsave() + await removeFeed({uri: item.uri}) + resetRemoveFeed() } catch (e) { Toast.show('There was an issue contacting your server') - console.error('Failed to unsave feed', {e}) + logger.error('Failed to unsave feed', {error: e}) } } else { try { - await item.pin() + await pinFeed({uri: item.uri}) + resetPinFeed() } catch (e) { Toast.show('There was an issue contacting your server') - console.error('Failed to pin feed', {e}) + logger.error('Failed to pin feed', {error: e}) } } } + return ( <View testID={`feed-${item.displayName}`}> <View @@ -66,10 +91,10 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ </Text> <Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}> - by {sanitizeHandle(item.creatorHandle, '@')} + by {sanitizeHandle(item.creator.handle, '@')} </Text> - {item.descriptionRT ? ( + {item.description ? ( <RichText type="xl" style={[ @@ -80,7 +105,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ marginBottom: 18, }, ]} - richText={item.descriptionRT} + richText={new BskRichText({text: item.description || ''})} numberOfLines={6} /> ) : null} @@ -97,7 +122,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ paddingRight: 2, gap: 6, }}> - {item.isSaved ? ( + {isPinned ? ( <> <FontAwesomeIcon icon="check" @@ -138,4 +163,4 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ </View> </View> ) -}) +} diff --git a/src/view/com/auth/onboarding/RecommendedFollows.tsx b/src/view/com/auth/onboarding/RecommendedFollows.tsx index f2710d2ac..372bbec6a 100644 --- a/src/view/com/auth/onboarding/RecommendedFollows.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollows.tsx @@ -1,7 +1,7 @@ import React from 'react' import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {AppBskyActorDefs, moderateProfile} from '@atproto/api' import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints' import {Text} from 'view/com/util/text/Text' import {ViewHeader} from 'view/com/util/ViewHeader' @@ -9,59 +9,62 @@ import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout' import {Button} from 'view/com/util/forms/Button' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {RecommendedFollowsItem} from './RecommendedFollowsItem' +import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' +import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows' +import {useModerationOpts} from '#/state/queries/preferences' +import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = { next: () => void } -export const RecommendedFollows = observer(function RecommendedFollowsImpl({ - next, -}: Props) { - const store = useStores() +export function RecommendedFollows({next}: Props) { const pal = usePalette('default') + const {_} = useLingui() const {isTabletOrMobile} = useWebMediaQueries() - - React.useEffect(() => { - // Load suggested actors if not already loaded - // prefetch should happen in the onboarding model - if ( - !store.onboarding.suggestedActors.hasLoaded || - store.onboarding.suggestedActors.isEmpty - ) { - store.onboarding.suggestedActors.loadMore(true) - } - }, [store]) + const {data: suggestedFollows} = useSuggestedFollowsQuery() + const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor() + const [additionalSuggestions, setAdditionalSuggestions] = React.useState<{ + [did: string]: AppBskyActorDefs.ProfileView[] + }>({}) + const existingDids = React.useRef<string[]>([]) + const moderationOpts = useModerationOpts() const title = ( <> - <Text - style={[ - pal.textLight, - tdStyles.title1, - isTabletOrMobile && tdStyles.title1Small, - ]}> - Follow some - </Text> - <Text - style={[ - pal.link, - tdStyles.title2, - isTabletOrMobile && tdStyles.title2Small, - ]}> - Recommended - </Text> - <Text - style={[ - pal.link, - tdStyles.title2, - isTabletOrMobile && tdStyles.title2Small, - ]}> - Users - </Text> + <Trans> + <Text + style={[ + pal.textLight, + tdStyles.title1, + isTabletOrMobile && tdStyles.title1Small, + ]}> + Follow some + </Text> + <Text + style={[ + pal.link, + tdStyles.title2, + isTabletOrMobile && tdStyles.title2Small, + ]}> + Recommended + </Text> + <Text + style={[ + pal.link, + tdStyles.title2, + isTabletOrMobile && tdStyles.title2Small, + ]}> + Users + </Text> + </Trans> <Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}> - Follow some users to get started. We can recommend you more users based - on who you find interesting. + <Trans> + Follow some users to get started. We can recommend you more users + based on who you find interesting. + </Trans> </Text> <View style={{ @@ -80,7 +83,7 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ <Text type="2xl-medium" style={{color: '#fff', position: 'relative', top: -1}}> - Done + <Trans>Done</Trans> </Text> <FontAwesomeIcon icon="angle-right" color="#fff" size={14} /> </View> @@ -89,6 +92,59 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ </> ) + const suggestions = React.useMemo(() => { + if (!suggestedFollows) return [] + + const additional = Object.entries(additionalSuggestions) + const items = suggestedFollows.pages.flatMap(page => page.actors) + + outer: while (additional.length) { + const additionalAccount = additional.shift() + + if (!additionalAccount) break + + const [followedUser, relatedAccounts] = additionalAccount + + for (let i = 0; i < items.length; i++) { + if (items[i].did === followedUser) { + items.splice(i + 1, 0, ...relatedAccounts) + continue outer + } + } + } + + existingDids.current = items.map(i => i.did) + + return items + }, [suggestedFollows, additionalSuggestions]) + + const onFollowStateChange = React.useCallback( + async ({following, did}: {following: boolean; did: string}) => { + if (following) { + try { + const {suggestions: results} = await getSuggestedFollowsByActor(did) + + if (results.length) { + const deduped = results.filter( + r => !existingDids.current.find(did => did === r.did), + ) + setAdditionalSuggestions(s => ({ + ...s, + [did]: deduped.slice(0, 3), + })) + } + } catch (e) { + logger.error('RecommendedFollows: failed to get suggestions', { + error: e, + }) + } + } + + // not handling the unfollow case + }, + [existingDids, getSuggestedFollowsByActor, setAdditionalSuggestions], + ) + return ( <> <TabletOrDesktop> @@ -98,15 +154,19 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ horizontal titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}} contentStyle={{paddingHorizontal: 0}}> - {store.onboarding.suggestedActors.isLoading ? ( + {!suggestedFollows || !moderationOpts ? ( <ActivityIndicator size="large" /> ) : ( <FlatList - data={store.onboarding.suggestedActors.suggestions} - renderItem={({item, index}) => ( - <RecommendedFollowsItem item={item} index={index} /> + data={suggestions} + renderItem={({item}) => ( + <RecommendedFollowsItem + profile={item} + onFollowStateChange={onFollowStateChange} + moderation={moderateProfile(item, moderationOpts)} + /> )} - keyExtractor={(item, index) => item.did + index.toString()} + keyExtractor={item => item.did} style={{flex: 1}} /> )} @@ -117,30 +177,36 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ <View style={[mStyles.container]} testID="recommendedFollowsOnboarding"> <View> <ViewHeader - title="Recommended Follows" + title={_(msg`Recommended Users`)} showBackButton={false} showOnDesktop /> <Text type="lg-medium" style={[pal.text, mStyles.header]}> - Check out some recommended users. Follow them to see similar - users. + <Trans> + Check out some recommended users. Follow them to see similar + users. + </Trans> </Text> </View> - {store.onboarding.suggestedActors.isLoading ? ( + {!suggestedFollows || !moderationOpts ? ( <ActivityIndicator size="large" /> ) : ( <FlatList - data={store.onboarding.suggestedActors.suggestions} - renderItem={({item, index}) => ( - <RecommendedFollowsItem item={item} index={index} /> + data={suggestions} + renderItem={({item}) => ( + <RecommendedFollowsItem + profile={item} + onFollowStateChange={onFollowStateChange} + moderation={moderateProfile(item, moderationOpts)} + /> )} - keyExtractor={(item, index) => item.did + index.toString()} + keyExtractor={item => item.did} style={{flex: 1}} /> )} <Button onPress={next} - label="Continue" + label={_(msg`Continue`)} testID="continueBtn" style={mStyles.button} labelStyle={mStyles.buttonText} @@ -149,7 +215,7 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ </Mobile> </> ) -}) +} const tdStyles = StyleSheet.create({ container: { diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx index 2b26918d0..93c515f38 100644 --- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx @@ -1,11 +1,8 @@ -import React, {useMemo} from 'react' +import React from 'react' import {View, StyleSheet, ActivityIndicator} from 'react-native' -import {AppBskyActorDefs, moderateProfile} from '@atproto/api' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' -import {FollowButton} from 'view/com/profile/FollowButton' +import {ProfileModeration, AppBskyActorDefs} from '@atproto/api' +import {Button} from '#/view/com/util/forms/Button' import {usePalette} from 'lib/hooks/usePalette' -import {SuggestedActor} from 'state/models/discovery/suggested-actors' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {s} from 'lib/styles' @@ -14,26 +11,32 @@ import {Text} from 'view/com/util/text/Text' import Animated, {FadeInRight} from 'react-native-reanimated' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useAnalytics} from 'lib/analytics/analytics' +import {Trans} from '@lingui/macro' +import {Shadow, useProfileShadow} from '#/state/cache/profile-shadow' +import {useProfileFollowMutationQueue} from '#/state/queries/profile' +import {logger} from '#/logger' type Props = { - item: SuggestedActor - index: number + profile: AppBskyActorDefs.ProfileViewBasic + moderation: ProfileModeration + onFollowStateChange: (props: { + did: string + following: boolean + }) => Promise<void> } -export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => { + +export function RecommendedFollowsItem({ + profile, + moderation, + onFollowStateChange, +}: React.PropsWithChildren<Props>) { const pal = usePalette('default') - const store = useStores() const {isMobile} = useWebMediaQueries() - const delay = useMemo(() => { - return ( - 50 * - (Math.abs(store.onboarding.suggestedActors.lastInsertedAtIndex - index) % - 5) - ) - }, [index, store.onboarding.suggestedActors.lastInsertedAtIndex]) + const shadowedProfile = useProfileShadow(profile) return ( <Animated.View - entering={FadeInRight.delay(delay).springify()} + entering={FadeInRight} style={[ styles.cardContainer, pal.view, @@ -43,24 +46,62 @@ export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => { borderRightWidth: isMobile ? undefined : 1, }, ]}> - <ProfileCard key={item.did} profile={item} index={index} /> + <ProfileCard + key={profile.did} + profile={shadowedProfile} + onFollowStateChange={onFollowStateChange} + moderation={moderation} + /> </Animated.View> ) } -export const ProfileCard = observer(function ProfileCardImpl({ +export function ProfileCard({ profile, - index, + onFollowStateChange, + moderation, }: { - profile: AppBskyActorDefs.ProfileViewBasic - index: number + profile: Shadow<AppBskyActorDefs.ProfileViewBasic> + moderation: ProfileModeration + onFollowStateChange: (props: { + did: string + following: boolean + }) => Promise<void> }) { const {track} = useAnalytics() - const store = useStores() const pal = usePalette('default') - const moderation = moderateProfile(profile, store.preferences.moderationOpts) const [addingMoreSuggestions, setAddingMoreSuggestions] = React.useState(false) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) + + const onToggleFollow = React.useCallback(async () => { + try { + if (profile.viewer?.following) { + await queueUnfollow() + } else { + setAddingMoreSuggestions(true) + await queueFollow() + await onFollowStateChange({did: profile.did, following: true}) + setAddingMoreSuggestions(false) + track('Onboarding:SuggestedFollowFollowed') + } + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('RecommendedFollows: failed to toggle following', { + error: e, + }) + } + } finally { + setAddingMoreSuggestions(false) + } + }, [ + profile, + queueFollow, + queueUnfollow, + setAddingMoreSuggestions, + track, + onFollowStateChange, + ]) return ( <View style={styles.card}> @@ -88,20 +129,11 @@ export const ProfileCard = observer(function ProfileCardImpl({ </Text> </View> - <FollowButton - profile={profile} + <Button + type={profile.viewer?.following ? 'default' : 'inverted'} labelStyle={styles.followButton} - onToggleFollow={async isFollow => { - if (isFollow) { - setAddingMoreSuggestions(true) - await store.onboarding.suggestedActors.insertSuggestionsByActor( - profile.did, - index, - ) - setAddingMoreSuggestions(false) - track('Onboarding:SuggestedFollowFollowed') - } - }} + onPress={onToggleFollow} + label={profile.viewer?.following ? 'Unfollow' : 'Follow'} /> </View> {profile.description ? ( @@ -114,12 +146,14 @@ export const ProfileCard = observer(function ProfileCardImpl({ {addingMoreSuggestions ? ( <View style={styles.addingMoreContainer}> <ActivityIndicator size="small" color={pal.colors.text} /> - <Text style={[pal.text]}>Finding similar accounts...</Text> + <Text style={[pal.text]}> + <Trans>Finding similar accounts...</Trans> + </Text> </View> ) : null} </View> ) -}) +} const styles = StyleSheet.create({ cardContainer: { diff --git a/src/view/com/auth/onboarding/WelcomeDesktop.tsx b/src/view/com/auth/onboarding/WelcomeDesktop.tsx index c066e9bd5..1a30c17f9 100644 --- a/src/view/com/auth/onboarding/WelcomeDesktop.tsx +++ b/src/view/com/auth/onboarding/WelcomeDesktop.tsx @@ -7,16 +7,13 @@ 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 {observer} from 'mobx-react-lite' type Props = { next: () => void skip: () => void } -export const WelcomeDesktop = observer(function WelcomeDesktopImpl({ - next, -}: Props) { +export function WelcomeDesktop({next}: Props) { const pal = usePalette('default') const horizontal = useMediaQuery({minWidth: 1300}) const title = ( @@ -105,7 +102,7 @@ export const WelcomeDesktop = observer(function WelcomeDesktopImpl({ </View> </TitleColumnLayout> ) -}) +} const styles = StyleSheet.create({ row: { diff --git a/src/view/com/auth/onboarding/WelcomeMobile.tsx b/src/view/com/auth/onboarding/WelcomeMobile.tsx index 1f0a64370..5de1a7817 100644 --- a/src/view/com/auth/onboarding/WelcomeMobile.tsx +++ b/src/view/com/auth/onboarding/WelcomeMobile.tsx @@ -5,18 +5,15 @@ import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Button} from 'view/com/util/forms/Button' -import {observer} from 'mobx-react-lite' import {ViewHeader} from 'view/com/util/ViewHeader' +import {Trans} from '@lingui/macro' type Props = { next: () => void skip: () => void } -export const WelcomeMobile = observer(function WelcomeMobileImpl({ - next, - skip, -}: Props) { +export function WelcomeMobile({next, skip}: Props) { const pal = usePalette('default') return ( @@ -32,7 +29,9 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({ accessibilityRole="button" style={[s.flexRow, s.alignCenter]} onPress={skip}> - <Text style={[pal.link]}>Skip</Text> + <Text style={[pal.link]}> + <Trans>Skip</Trans> + </Text> <FontAwesomeIcon icon={'chevron-right'} size={14} @@ -44,18 +43,22 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({ /> <View> <Text style={[pal.text, styles.title]}> - Welcome to{' '} - <Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text> + <Trans> + Welcome to{' '} + <Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text> + </Trans> </Text> <View style={styles.spacer} /> <View style={[styles.row]}> <FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} /> <View style={[styles.rowText]}> <Text type="lg-bold" style={[pal.text]}> - Bluesky is public. + <Trans>Bluesky is public.</Trans> </Text> <Text type="lg-thin" 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 const WelcomeMobile = observer(function WelcomeMobileImpl({ <FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} /> <View style={[styles.rowText]}> <Text type="lg-bold" style={[pal.text]}> - Bluesky is open. + <Trans>Bluesky is open.</Trans> </Text> <Text type="lg-thin" 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,11 +77,13 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({ <FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} /> <View style={[styles.rowText]}> <Text type="lg-bold" style={[pal.text]}> - Bluesky is flexible. + <Trans>Bluesky is flexible.</Trans> </Text> <Text type="lg-thin" 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> @@ -93,7 +98,7 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({ /> </View> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/auth/withAuthRequired.tsx b/src/view/com/auth/withAuthRequired.tsx deleted file mode 100644 index 25d12165f..000000000 --- a/src/view/com/auth/withAuthRequired.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from 'react' -import { - ActivityIndicator, - Linking, - StyleSheet, - TouchableOpacity, -} from 'react-native' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' -import {CenteredView} from '../util/Views' -import {LoggedOut} from './LoggedOut' -import {Onboarding} from './Onboarding' -import {Text} from '../util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {STATUS_PAGE_URL} from 'lib/constants' - -export const withAuthRequired = <P extends object>( - Component: React.ComponentType<P>, -): React.FC<P> => - observer(function AuthRequired(props: P) { - const store = useStores() - if (store.session.isResumingSession) { - return <Loading /> - } - if (!store.session.hasSession) { - return <LoggedOut /> - } - if (store.onboarding.isActive) { - return <Onboarding /> - } - return <Component {...props} /> - }) - -function Loading() { - const pal = usePalette('default') - - const [isTakingTooLong, setIsTakingTooLong] = React.useState(false) - React.useEffect(() => { - const t = setTimeout(() => setIsTakingTooLong(true), 15e3) // 15 seconds - return () => clearTimeout(t) - }, [setIsTakingTooLong]) - - return ( - <CenteredView style={[styles.loading, pal.view]}> - <ActivityIndicator size="large" /> - <Text type="2xl" style={[styles.loadingText, pal.textLight]}> - {isTakingTooLong - ? "This is taking too long. There may be a problem with your internet or with the service, but we're going to try a couple more times..." - : 'Connecting...'} - </Text> - {isTakingTooLong ? ( - <TouchableOpacity - onPress={() => { - Linking.openURL(STATUS_PAGE_URL) - }} - accessibilityRole="button"> - <Text type="2xl" style={[styles.loadingText, pal.link]}> - Check Bluesky status page - </Text> - </TouchableOpacity> - ) : null} - </CenteredView> - ) -} - -const styles = StyleSheet.create({ - loading: { - height: '100%', - alignContent: 'center', - justifyContent: 'center', - paddingBottom: 100, - }, - loadingText: { - paddingVertical: 20, - paddingHorizontal: 20, - textAlign: 'center', - }, -}) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index e44a0ce01..6f058d39e 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -16,7 +16,6 @@ import LinearGradient from 'react-native-linear-gradient' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {RichText} from '@atproto/api' import {useAnalytics} from 'lib/analytics/analytics' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible' import {ExternalEmbed} from './ExternalEmbed' import {Text} from '../util/text/Text' @@ -26,9 +25,8 @@ import * as Toast from '../util/Toast' import {TextInput, TextInputRef} from './text-input/TextInput' import {CharProgress} from './char-progress/CharProgress' import {UserAvatar} from '../util/UserAvatar' -import {useStores} from 'state/index' import * as apilib from 'lib/api/index' -import {ComposerOpts} from 'state/models/ui/shell' +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' @@ -49,6 +47,18 @@ import {LabelsBtn} from './labels/LabelsBtn' import {SelectLangBtn} from './select-language/SelectLangBtn' import {EmojiPickerButton} from './text-input/web/EmojiPicker.web' import {insertMentionAt} from 'lib/strings/mention-manip' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModals, useModalControls} from '#/state/modals' +import {useRequireAltTextEnabled} from '#/state/preferences' +import { + useLanguagePrefs, + useLanguagePrefsApi, + toPostLanguages, +} from '#/state/preferences/languages' +import {useSession, getAgent} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' +import {useComposerControls} from '#/state/shell/composer' type Props = ComposerOpts export const ComposePost = observer(function ComposePost({ @@ -57,10 +67,18 @@ export const ComposePost = observer(function ComposePost({ quote: initQuote, mention: initMention, }: Props) { + const {currentAccount} = useSession() + const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) + const {activeModals} = useModals() + const {openModal, closeModal} = useModalControls() + const {closeComposer} = useComposerControls() const {track} = useAnalytics() const pal = usePalette('default') const {isDesktop, isMobile} = useWebMediaQueries() - const store = useStores() + const {_} = useLingui() + const requireAltTextEnabled = useRequireAltTextEnabled() + const langPrefs = useLanguagePrefs() + const setLangPrefs = useLanguagePrefsApi() const textInput = useRef<TextInputRef>(null) const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) const [isProcessing, setIsProcessing] = useState(false) @@ -86,15 +104,10 @@ export const ComposePost = observer(function ComposePost({ const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const [labels, setLabels] = useState<string[]>([]) const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) - const gallery = useMemo(() => new GalleryModel(store), [store]) + const gallery = useMemo(() => new GalleryModel(), []) const onClose = useCallback(() => { - store.shell.closeComposer() - }, [store]) - - const autocompleteView = useMemo<UserAutocompleteModel>( - () => new UserAutocompleteModel(store), - [store], - ) + closeComposer() + }, [closeComposer]) const insets = useSafeAreaInsets() const viewStyles = useMemo( @@ -108,27 +121,27 @@ export const ComposePost = observer(function ComposePost({ const onPressCancel = useCallback(() => { if (graphemeLength > 0 || !gallery.isEmpty) { - if (store.shell.activeModals.some(modal => modal.name === 'confirm')) { - store.shell.closeModal() + if (activeModals.some(modal => modal.name === 'confirm')) { + closeModal() } if (Keyboard) { Keyboard.dismiss() } - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Discard draft', + title: _(msg`Discard draft`), onPressConfirm: onClose, onPressCancel: () => { - store.shell.closeModal() + closeModal() }, - message: "Are you sure you'd like to discard this draft?", - confirmBtnText: 'Discard', + message: _(msg`Are you sure you'd like to discard this draft?`), + confirmBtnText: _(msg`Discard`), confirmBtnStyle: {backgroundColor: colors.red4}, }) } else { onClose() } - }, [store, onClose, graphemeLength, gallery]) + }, [openModal, closeModal, activeModals, onClose, graphemeLength, gallery, _]) // android back button useEffect(() => { if (!isAndroid) { @@ -147,11 +160,6 @@ export const ComposePost = observer(function ComposePost({ } }, [onPressCancel]) - // initial setup - useEffect(() => { - autocompleteView.setup() - }, [autocompleteView]) - // listen to escape key on desktop web const onEscape = useCallback( (e: KeyboardEvent) => { @@ -187,7 +195,7 @@ export const ComposePost = observer(function ComposePost({ if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { return } - if (store.preferences.requireAltTextEnabled && gallery.needsAltText) { + if (requireAltTextEnabled && gallery.needsAltText) { return } @@ -201,7 +209,7 @@ export const ComposePost = observer(function ComposePost({ setIsProcessing(true) try { - await apilib.post(store, { + await apilib.post(getAgent(), { rawText: richtext.text, replyTo: replyTo?.uri, images: gallery.images, @@ -209,8 +217,7 @@ export const ComposePost = observer(function ComposePost({ extLink, labels, onStateChange: setProcessingState, - knownHandles: autocompleteView.knownHandles, - langs: store.preferences.postLanguages, + langs: toPostLanguages(langPrefs.postLanguage), }) } catch (e: any) { if (extLink) { @@ -230,9 +237,9 @@ export const ComposePost = observer(function ComposePost({ if (replyTo && replyTo.uri) track('Post:Reply') } if (!replyTo) { - store.me.mainFeed.onPostCreated() + // TODO onPostCreated } - store.preferences.savePostLanguageToHistory() + setLangPrefs.savePostLanguageToHistory() onPost?.() onClose() Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) @@ -241,12 +248,8 @@ export const ComposePost = observer(function ComposePost({ const canPost = useMemo( () => graphemeLength <= MAX_GRAPHEME_LENGTH && - (!store.preferences.requireAltTextEnabled || !gallery.needsAltText), - [ - graphemeLength, - store.preferences.requireAltTextEnabled, - gallery.needsAltText, - ], + (!requireAltTextEnabled || !gallery.needsAltText), + [graphemeLength, requireAltTextEnabled, gallery.needsAltText], ) const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?` @@ -265,9 +268,11 @@ export const ComposePost = observer(function ComposePost({ onPress={onPressCancel} onAccessibilityEscape={onPressCancel} accessibilityRole="button" - accessibilityLabel="Cancel" + accessibilityLabel={_(msg`Cancel`)} accessibilityHint="Closes post composer and discards post draft"> - <Text style={[pal.link, s.f18]}>Cancel</Text> + <Text style={[pal.link, s.f18]}> + <Trans>Cancel</Trans> + </Text> </TouchableOpacity> <View style={s.flex1} /> {isProcessing ? ( @@ -308,13 +313,15 @@ export const ComposePost = observer(function ComposePost({ </TouchableOpacity> ) : ( <View style={[styles.postBtn, pal.btn]}> - <Text style={[pal.textLight, s.f16, s.bold]}>Post</Text> + <Text style={[pal.textLight, s.f16, s.bold]}> + <Trans>Post</Trans> + </Text> </View> )} </> )} </View> - {store.preferences.requireAltTextEnabled && gallery.needsAltText && ( + {requireAltTextEnabled && gallery.needsAltText && ( <View style={[styles.reminderLine, pal.viewLight]}> <View style={styles.errorIcon}> <FontAwesomeIcon @@ -324,7 +331,7 @@ export const ComposePost = observer(function ComposePost({ /> </View> <Text style={[pal.text, s.flex1]}> - One or more images is missing alt text. + <Trans>One or more images is missing alt text.</Trans> </Text> </View> )} @@ -366,13 +373,12 @@ export const ComposePost = observer(function ComposePost({ styles.textInputLayout, isNative && styles.textInputLayoutMobile, ]}> - <UserAvatar avatar={store.me.avatar} size={50} /> + <UserAvatar avatar={currentProfile?.avatar} size={50} /> <TextInput ref={textInput} richtext={richtext} placeholder={selectTextInputPlaceholder} suggestedLinks={suggestedLinks} - autocompleteView={autocompleteView} autoFocus={true} setRichText={setRichText} onPhotoPasted={onPhotoPasted} @@ -380,7 +386,7 @@ export const ComposePost = observer(function ComposePost({ onSuggestedLinksChanged={setSuggestedLinks} onError={setError} accessible={true} - accessibilityLabel="Write post" + accessibilityLabel={_(msg`Write post`)} accessibilityHint={`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`} /> </View> @@ -409,11 +415,11 @@ export const ComposePost = observer(function ComposePost({ style={[pal.borderDark, styles.addExtLinkBtn]} onPress={() => onPressAddLinkCard(url)} accessibilityRole="button" - accessibilityLabel="Add link card" + accessibilityLabel={_(msg`Add link card`)} accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}> <Text style={pal.text}> - Add link card:{' '} - <Text style={pal.link}>{toShortUrl(url)}</Text> + <Trans>Add link card:</Trans> + <Text style={[pal.link, s.ml5]}>{toShortUrl(url)}</Text> </Text> </TouchableOpacity> ))} diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index c9200ec63..502e4b4d2 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -11,6 +11,8 @@ import {Text} from '../util/text/Text' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {ExternalEmbedDraft} from 'lib/api/index' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' export const ExternalEmbed = ({ link, @@ -21,6 +23,7 @@ export const ExternalEmbed = ({ }) => { const pal = usePalette('default') const palError = usePalette('error') + const {_} = useLingui() if (!link) { return <View /> } @@ -64,7 +67,7 @@ export const ExternalEmbed = ({ style={styles.removeBtn} onPress={onRemove} accessibilityRole="button" - accessibilityLabel="Remove image preview" + accessibilityLabel={_(msg`Remove image preview`)} accessibilityHint={`Removes default thumbnail from ${link.uri}`} onAccessibilityEscape={onRemove}> <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx index e54404f52..ae055f9ac 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/composer/Prompt.tsx @@ -3,12 +3,17 @@ import {StyleSheet, TouchableOpacity} from 'react-native' import {UserAvatar} from '../util/UserAvatar' import {Text} from '../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSession} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { - const store = useStores() + const {currentAccount} = useSession() + const {data: profile} = useProfileQuery({did: currentAccount?.did}) const pal = usePalette('default') + const {_} = useLingui() const {isDesktop} = useWebMediaQueries() return ( <TouchableOpacity @@ -16,16 +21,16 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { style={[pal.view, pal.border, styles.prompt]} onPress={() => onPressCompose()} accessibilityRole="button" - accessibilityLabel="Compose reply" + accessibilityLabel={_(msg`Compose reply`)} accessibilityHint="Opens composer"> - <UserAvatar avatar={store.me.avatar} size={38} /> + <UserAvatar avatar={profile?.avatar} size={38} /> <Text type="xl" style={[ pal.text, isDesktop ? styles.labelDesktopWeb : styles.labelMobile, ]}> - Write your reply + <Trans>Write your reply</Trans> </Text> </TouchableOpacity> ) diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx index 96908d47f..a10684691 100644 --- a/src/view/com/composer/labels/LabelsBtn.tsx +++ b/src/view/com/composer/labels/LabelsBtn.tsx @@ -1,15 +1,16 @@ import React from 'react' import {Keyboard, StyleSheet} from 'react-native' -import {observer} from 'mobx-react-lite' import {Button} from 'view/com/util/forms/Button' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {ShieldExclamation} from 'lib/icons' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {isNative} from 'platform/detection' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {useModalControls} from '#/state/modals' -export const LabelsBtn = observer(function LabelsBtn({ +export function LabelsBtn({ labels, hasMedia, onChange, @@ -19,14 +20,15 @@ export const LabelsBtn = observer(function LabelsBtn({ onChange: (v: string[]) => void }) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const {openModal} = useModalControls() return ( <Button type="default-light" testID="labelsBtn" style={[styles.button, !hasMedia && styles.dimmed]} - accessibilityLabel="Content warnings" + accessibilityLabel={_(msg`Content warnings`)} accessibilityHint="" onPress={() => { if (isNative) { @@ -34,7 +36,7 @@ export const LabelsBtn = observer(function LabelsBtn({ Keyboard.dismiss() } } - store.shell.openModal({name: 'self-label', labels, hasMedia, onChange}) + openModal({name: 'self-label', labels, hasMedia, onChange}) }}> <ShieldExclamation style={pal.link} size={26} /> {labels.length > 0 ? ( @@ -46,7 +48,7 @@ export const LabelsBtn = observer(function LabelsBtn({ ) : null} </Button> ) -}) +} const styles = StyleSheet.create({ button: { diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index fcd99842a..69c8debb0 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -7,11 +7,13 @@ import {s, colors} from 'lib/styles' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {Image} from 'expo-image' import {Text} from 'view/com/util/text/Text' -import {openAltTextModal} from 'lib/media/alt-text' import {Dimensions} from 'lib/media/types' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {isNative} from 'platform/detection' const IMAGE_GAP = 8 @@ -47,9 +49,10 @@ const GalleryInner = observer(function GalleryImpl({ gallery, containerInfo, }: GalleryInnerProps) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() const {isMobile} = useWebMediaQueries() + const {openModal} = useModalControls() let side: number @@ -113,15 +116,18 @@ const GalleryInner = observer(function GalleryImpl({ <TouchableOpacity testID="altTextButton" accessibilityRole="button" - accessibilityLabel="Add alt text" + accessibilityLabel={_(msg`Add alt text`)} accessibilityHint="" onPress={() => { Keyboard.dismiss() - openAltTextModal(store, image) + openModal({ + name: 'alt-text-image', + image, + }) }} style={[styles.altTextControl, altTextControlStyle]}> <Text style={styles.altTextControlLabel} accessible={false}> - ALT + <Trans>ALT</Trans> </Text> {image.altText.length > 0 ? ( <FontAwesomeIcon @@ -135,9 +141,19 @@ const GalleryInner = observer(function GalleryImpl({ <TouchableOpacity testID="editPhotoButton" accessibilityRole="button" - accessibilityLabel="Edit image" + accessibilityLabel={_(msg`Edit image`)} accessibilityHint="" - onPress={() => gallery.edit(image)} + onPress={() => { + if (isNative) { + gallery.crop(image) + } else { + openModal({ + name: 'edit-image', + image, + gallery, + }) + } + }} style={styles.imageControl}> <FontAwesomeIcon icon="pen" @@ -148,7 +164,7 @@ const GalleryInner = observer(function GalleryImpl({ <TouchableOpacity testID="removePhotoButton" accessibilityRole="button" - accessibilityLabel="Remove image" + accessibilityLabel={_(msg`Remove image`)} accessibilityHint="" onPress={() => gallery.remove(image)} style={styles.imageControl}> @@ -161,11 +177,14 @@ const GalleryInner = observer(function GalleryImpl({ </View> <TouchableOpacity accessibilityRole="button" - accessibilityLabel="Add alt text" + accessibilityLabel={_(msg`Add alt text`)} accessibilityHint="" onPress={() => { Keyboard.dismiss() - openAltTextModal(store, image) + openModal({ + name: 'alt-text-image', + image, + }) }} style={styles.altTextHiddenRegion} /> @@ -187,8 +206,10 @@ const GalleryInner = observer(function GalleryImpl({ <FontAwesomeIcon icon="info" size={12} color={pal.colors.text} /> </View> <Text type="sm" style={[pal.textLight, s.flex1]}> - Alt text describes images for blind and low-vision users, and helps - give context to everyone. + <Trans> + Alt text describes images for blind and low-vision users, and helps + give context to everyone. + </Trans> </Text> </View> </> diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index 99e820d51..69f63c55f 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -6,13 +6,14 @@ import { } from '@fortawesome/react-native-fontawesome' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' -import {useStores} from 'state/index' import {openCamera} from 'lib/media/picker' import {useCameraPermission} from 'lib/hooks/usePermissions' import {HITSLOP_10, POST_IMG_MAX} from 'lib/constants' import {GalleryModel} from 'state/models/media/gallery' import {isMobileWeb, isNative} from 'platform/detection' import {logger} from '#/logger' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' type Props = { gallery: GalleryModel @@ -21,7 +22,7 @@ type Props = { export function OpenCameraBtn({gallery}: Props) { const pal = usePalette('default') const {track} = useAnalytics() - const store = useStores() + const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const onPressTakePicture = useCallback(async () => { @@ -31,7 +32,7 @@ export function OpenCameraBtn({gallery}: Props) { return } - const img = await openCamera(store, { + const img = await openCamera({ width: POST_IMG_MAX.width, height: POST_IMG_MAX.height, freeStyleCropEnabled: true, @@ -42,7 +43,7 @@ export function OpenCameraBtn({gallery}: Props) { // ignore logger.warn('Error using camera', {error: err}) } - }, [gallery, track, store, requestCameraAccessIfNeeded]) + }, [gallery, track, requestCameraAccessIfNeeded]) const shouldShowCameraButton = isNative || isMobileWeb if (!shouldShowCameraButton) { @@ -56,7 +57,7 @@ export function OpenCameraBtn({gallery}: Props) { style={styles.button} hitSlop={HITSLOP_10} accessibilityRole="button" - accessibilityLabel="Camera" + accessibilityLabel={_(msg`Camera`)} accessibilityHint="Opens camera on device"> <FontAwesomeIcon icon="camera" diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx index a6826eb98..af0a22b01 100644 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -10,6 +10,8 @@ import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' import {GalleryModel} from 'state/models/media/gallery' import {HITSLOP_10} from 'lib/constants' import {isNative} from 'platform/detection' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' type Props = { gallery: GalleryModel @@ -18,6 +20,7 @@ type Props = { export function SelectPhotoBtn({gallery}: Props) { const pal = usePalette('default') const {track} = useAnalytics() + const {_} = useLingui() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const onPressSelectPhotos = useCallback(async () => { @@ -37,7 +40,7 @@ export function SelectPhotoBtn({gallery}: Props) { style={styles.button} hitSlop={HITSLOP_10} accessibilityRole="button" - accessibilityLabel="Gallery" + accessibilityLabel={_(msg`Gallery`)} accessibilityHint="Opens device photo gallery"> <FontAwesomeIcon icon={['far', 'image']} diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx index 4faac3750..78b1e9ba2 100644 --- a/src/view/com/composer/select-language/SelectLangBtn.tsx +++ b/src/view/com/composer/select-language/SelectLangBtn.tsx @@ -1,6 +1,5 @@ import React, {useCallback, useMemo} from 'react' import {StyleSheet, Keyboard} from 'react-native' -import {observer} from 'mobx-react-lite' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -12,13 +11,24 @@ import { DropdownItemButton, } from 'view/com/util/forms/DropdownButton' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {isNative} from 'platform/detection' import {codeToLanguageName} from '../../../../locale/helpers' +import {useModalControls} from '#/state/modals' +import { + useLanguagePrefs, + useLanguagePrefsApi, + toPostLanguages, + hasPostLanguage, +} from '#/state/preferences/languages' +import {t, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' -export const SelectLangBtn = observer(function SelectLangBtn() { +export function SelectLangBtn() { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const {openModal} = useModalControls() + const langPrefs = useLanguagePrefs() + const setLangPrefs = useLanguagePrefsApi() const onPressMore = useCallback(async () => { if (isNative) { @@ -26,11 +36,10 @@ export const SelectLangBtn = observer(function SelectLangBtn() { Keyboard.dismiss() } } - store.shell.openModal({name: 'post-languages-settings'}) - }, [store]) + openModal({name: 'post-languages-settings'}) + }, [openModal]) - const postLanguagesPref = store.preferences.postLanguages - const postLanguagePref = store.preferences.postLanguage + const postLanguagesPref = toPostLanguages(langPrefs.postLanguage) const items: DropdownItem[] = useMemo(() => { let arr: DropdownItemButton[] = [] @@ -49,13 +58,14 @@ export const SelectLangBtn = observer(function SelectLangBtn() { arr.push({ icon: - langCodes.every(code => store.preferences.hasPostLanguage(code)) && - langCodes.length === postLanguagesPref.length + langCodes.every(code => + hasPostLanguage(langPrefs.postLanguage, code), + ) && langCodes.length === postLanguagesPref.length ? ['fas', 'circle-dot'] : ['far', 'circle'], label: langName, onPress() { - store.preferences.setPostLanguage(commaSeparatedLangCodes) + setLangPrefs.setPostLanguage(commaSeparatedLangCodes) }, }) } @@ -65,24 +75,24 @@ export const SelectLangBtn = observer(function SelectLangBtn() { * Re-join here after sanitization bc postLanguageHistory is an array of * comma-separated strings too */ - add(postLanguagePref) + add(langPrefs.postLanguage) } // comma-separted strings of lang codes that have been used in the past - for (const lang of store.preferences.postLanguageHistory) { + for (const lang of langPrefs.postLanguageHistory) { add(lang) } return [ - {heading: true, label: 'Post language'}, + {heading: true, label: t`Post language`}, ...arr.slice(0, 6), {sep: true}, { - label: 'Other...', + label: t`Other...`, onPress: onPressMore, }, ] - }, [store.preferences, onPressMore, postLanguagePref, postLanguagesPref]) + }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref]) return ( <DropdownButton @@ -91,7 +101,7 @@ export const SelectLangBtn = observer(function SelectLangBtn() { items={items} openUpwards style={styles.button} - accessibilityLabel="Language selection" + accessibilityLabel={_(msg`Language selection`)} accessibilityHint=""> {postLanguagesPref.length > 0 ? ( <Text type="lg-bold" style={[pal.link, styles.label]} numberOfLines={1}> @@ -106,7 +116,7 @@ export const SelectLangBtn = observer(function SelectLangBtn() { )} </DropdownButton> ) -}) +} const styles = StyleSheet.create({ button: { diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 2810129f6..13fe3a0b3 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useRef, useMemo, + useState, ComponentProps, } from 'react' import { @@ -18,7 +19,6 @@ import PasteInput, { } from '@mattermost/react-native-paste-input' import {AppBskyRichtextFacet, RichText} from '@atproto/api' import isEqual from 'lodash.isequal' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {Autocomplete} from './mobile/Autocomplete' import {Text} from 'view/com/util/text/Text' import {cleanError} from 'lib/strings/errors' @@ -38,7 +38,6 @@ interface TextInputProps extends ComponentProps<typeof RNTextInput> { richtext: RichText placeholder: string suggestedLinks: Set<string> - autocompleteView: UserAutocompleteModel setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void onPressPublish: (richtext: RichText) => Promise<void> @@ -56,7 +55,6 @@ export const TextInput = forwardRef(function TextInputImpl( richtext, placeholder, suggestedLinks, - autocompleteView, setRichText, onPhotoPasted, onSuggestedLinksChanged, @@ -69,6 +67,7 @@ export const TextInput = forwardRef(function TextInputImpl( const textInput = useRef<PasteInputRef>(null) const textInputSelection = useRef<Selection>({start: 0, end: 0}) const theme = useTheme() + const [autocompletePrefix, setAutocompletePrefix] = useState('') React.useImperativeHandle(ref, () => ({ focus: () => textInput.current?.focus(), @@ -99,10 +98,9 @@ export const TextInput = forwardRef(function TextInputImpl( textInputSelection.current?.start || 0, ) if (prefix) { - autocompleteView.setActive(true) - autocompleteView.setPrefix(prefix.value) - } else { - autocompleteView.setActive(false) + setAutocompletePrefix(prefix.value) + } else if (autocompletePrefix) { + setAutocompletePrefix('') } const set: Set<string> = new Set() @@ -139,7 +137,8 @@ export const TextInput = forwardRef(function TextInputImpl( }, [ setRichText, - autocompleteView, + autocompletePrefix, + setAutocompletePrefix, suggestedLinks, onSuggestedLinksChanged, onPhotoPasted, @@ -179,9 +178,9 @@ export const TextInput = forwardRef(function TextInputImpl( item, ), ) - autocompleteView.setActive(false) + setAutocompletePrefix('') }, - [onChangeText, richtext, autocompleteView], + [onChangeText, richtext, setAutocompletePrefix], ) const textDecorated = useMemo(() => { @@ -221,7 +220,7 @@ export const TextInput = forwardRef(function TextInputImpl( {textDecorated} </PasteInput> <Autocomplete - view={autocompleteView} + prefix={autocompletePrefix} onSelect={onSelectAutocompleteItem} /> </View> diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 35482bc70..4c31da338 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -11,13 +11,13 @@ import {Paragraph} from '@tiptap/extension-paragraph' import {Placeholder} from '@tiptap/extension-placeholder' import {Text} from '@tiptap/extension-text' import isEqual from 'lodash.isequal' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {createSuggestion} from './web/Autocomplete' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {isUriImage, blobToDataUri} from 'lib/media/util' import {Emoji} from './web/EmojiPicker.web' import {LinkDecorator} from './web/LinkDecorator' import {generateJSON} from '@tiptap/html' +import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' export interface TextInputRef { focus: () => void @@ -28,7 +28,6 @@ interface TextInputProps { richtext: RichText placeholder: string suggestedLinks: Set<string> - autocompleteView: UserAutocompleteModel setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void onPressPublish: (richtext: RichText) => Promise<void> @@ -43,7 +42,6 @@ export const TextInput = React.forwardRef(function TextInputImpl( richtext, placeholder, suggestedLinks, - autocompleteView, setRichText, onPhotoPasted, onPressPublish, @@ -52,6 +50,8 @@ export const TextInput = React.forwardRef(function TextInputImpl( TextInputProps, ref, ) { + const autocomplete = useActorAutocompleteFn() + const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark') const extensions = React.useMemo( () => [ @@ -61,7 +61,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( HTMLAttributes: { class: 'mention', }, - suggestion: createSuggestion({autocompleteView}), + suggestion: createSuggestion({autocomplete}), }), Paragraph, Placeholder.configure({ @@ -71,7 +71,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( History, Hardbreak, ], - [autocompleteView, placeholder], + [autocomplete, placeholder], ) React.useEffect(() => { diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx index f8335d4b9..c400aa48d 100644 --- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -1,31 +1,40 @@ -import React, {useEffect} from 'react' +import React, {useEffect, useRef} from 'react' import {Animated, TouchableOpacity, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' 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 {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' +import {Trans} from '@lingui/macro' +import {AppBskyActorDefs} from '@atproto/api' -export const Autocomplete = observer(function AutocompleteImpl({ - view, +export function Autocomplete({ + prefix, onSelect, }: { - view: UserAutocompleteModel + prefix: string onSelect: (item: string) => void }) { const pal = usePalette('default') const positionInterp = useAnimatedValue(0) const {getGraphemeString} = useGrapheme() + const isActive = !!prefix + const {data: suggestions, isFetching} = useActorAutocompleteQuery(prefix) + const suggestionsRef = useRef< + AppBskyActorDefs.ProfileViewBasic[] | undefined + >(undefined) + if (suggestions) { + suggestionsRef.current = suggestions + } useEffect(() => { Animated.timing(positionInterp, { - toValue: view.isActive ? 1 : 0, + toValue: isActive ? 1 : 0, duration: 200, useNativeDriver: true, }).start() - }, [positionInterp, view.isActive]) + }, [positionInterp, isActive]) const topAnimStyle = { transform: [ @@ -40,10 +49,10 @@ export const Autocomplete = observer(function AutocompleteImpl({ return ( <Animated.View style={topAnimStyle}> - {view.isActive ? ( + {isActive ? ( <View style={[pal.view, styles.container, pal.border]}> - {view.suggestions.length > 0 ? ( - view.suggestions.slice(0, 5).map(item => { + {suggestionsRef.current?.length ? ( + suggestionsRef.current.slice(0, 5).map(item => { // Eventually use an average length const MAX_CHARS = 40 const MAX_HANDLE_CHARS = 20 @@ -82,14 +91,18 @@ export const Autocomplete = observer(function AutocompleteImpl({ }) ) : ( <Text type="sm" style={[pal.text, pal.border, styles.noResults]}> - No result + {isFetching ? ( + <Trans>Loading...</Trans> + ) : ( + <Trans>No result</Trans> + )} </Text> )} </View> ) : null} </Animated.View> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index bbed26d48..1f7412561 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -12,7 +12,7 @@ import { SuggestionProps, SuggestionKeyDownProps, } from '@tiptap/suggestion' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' +import {ActorAutocompleteFn} from '#/state/queries/actor-autocomplete' import {usePalette} from 'lib/hooks/usePalette' import {Text} from 'view/com/util/text/Text' import {UserAvatar} from 'view/com/util/UserAvatar' @@ -23,15 +23,14 @@ interface MentionListRef { } export function createSuggestion({ - autocompleteView, + autocomplete, }: { - autocompleteView: UserAutocompleteModel + autocomplete: ActorAutocompleteFn }): Omit<SuggestionOptions, 'editor'> { return { async items({query}) { - autocompleteView.setActive(true) - await autocompleteView.setPrefix(query) - return autocompleteView.suggestions.slice(0, 8) + const suggestions = await autocomplete({query}) + return suggestions.slice(0, 8) }, render: () => { diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts index eda1a6704..ef3958c9d 100644 --- a/src/view/com/composer/useExternalLinkFetch.ts +++ b/src/view/com/composer/useExternalLinkFetch.ts @@ -1,5 +1,4 @@ import {useState, useEffect} from 'react' -import {useStores} from 'state/index' import {ImageModel} from 'state/models/media/image' import * as apilib from 'lib/api/index' import {getLinkMeta} from 'lib/link-meta/link-meta' @@ -14,19 +13,21 @@ import { isBskyCustomFeedUrl, isBskyListUrl, } from 'lib/strings/url-helpers' -import {ComposerOpts} from 'state/models/ui/shell' +import {ComposerOpts} from 'state/shell/composer' import {POST_IMG_MAX} from 'lib/constants' import {logger} from '#/logger' +import {getAgent} from '#/state/session' +import {useGetPost} from '#/state/queries/post' export function useExternalLinkFetch({ setQuote, }: { setQuote: (opts: ComposerOpts['quote']) => void }) { - const store = useStores() const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>( undefined, ) + const getPost = useGetPost() useEffect(() => { let aborted = false @@ -38,7 +39,7 @@ export function useExternalLinkFetch({ } if (!extLink.meta) { if (isBskyPostUrl(extLink.uri)) { - getPostAsQuote(store, extLink.uri).then( + getPostAsQuote(getPost, extLink.uri).then( newQuote => { if (aborted) { return @@ -48,13 +49,13 @@ export function useExternalLinkFetch({ }, err => { logger.error('Failed to fetch post for quote embedding', { - error: err, + error: err.toString(), }) setExtLink(undefined) }, ) } else if (isBskyCustomFeedUrl(extLink.uri)) { - getFeedAsEmbed(store, extLink.uri).then( + getFeedAsEmbed(getAgent(), extLink.uri).then( ({embed, meta}) => { if (aborted) { return @@ -72,7 +73,7 @@ export function useExternalLinkFetch({ }, ) } else if (isBskyListUrl(extLink.uri)) { - getListAsEmbed(store, extLink.uri).then( + getListAsEmbed(getAgent(), extLink.uri).then( ({embed, meta}) => { if (aborted) { return @@ -90,7 +91,7 @@ export function useExternalLinkFetch({ }, ) } else { - getLinkMeta(store, extLink.uri).then(meta => { + getLinkMeta(getAgent(), extLink.uri).then(meta => { if (aborted) { return } @@ -120,9 +121,7 @@ export function useExternalLinkFetch({ setExtLink({ ...extLink, isLoading: false, // done - localThumb: localThumb - ? new ImageModel(store, localThumb) - : undefined, + localThumb: localThumb ? new ImageModel(localThumb) : undefined, }) }) return cleanup @@ -134,7 +133,7 @@ export function useExternalLinkFetch({ }) } return cleanup - }, [store, extLink, setQuote]) + }, [extLink, setQuote, getPost]) return {extLink, setExtLink} } diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 1037007b7..f3f07a8bd 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -4,74 +4,56 @@ import { } from '@fortawesome/react-native-fontawesome' import {useIsFocused} from '@react-navigation/native' import {useAnalytics} from '@segment/analytics-react-native' +import {useQueryClient} from '@tanstack/react-query' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {ComposeIcon2} from 'lib/icons' import {colors, s} from 'lib/styles' -import {observer} from 'mobx-react-lite' import React from 'react' -import {FlatList, View} from 'react-native' -import {useStores} from 'state/index' -import {PostsFeedModel} from 'state/models/feeds/posts' -import {useHeaderOffset, POLL_FREQ} from 'view/screens/Home' +import {FlatList, View, useWindowDimensions} from 'react-native' import {Feed} from '../posts/Feed' import {TextLink} from '../util/Link' import {FAB} from '../util/fab/FAB' import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn' -import useAppState from 'react-native-appstate-hook' -import {logger} from '#/logger' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSession} from '#/state/session' +import {useComposerControls} from '#/state/shell/composer' +import {listenSoftReset, emitSoftReset} from '#/state/events' +import {truncateAndInvalidate} from '#/state/queries/util' -export const FeedPage = observer(function FeedPageImpl({ +const POLL_FREQ = 30e3 // 30sec + +export function FeedPage({ testID, isPageFocused, feed, + feedParams, renderEmptyState, renderEndOfFeed, }: { testID?: string - feed: PostsFeedModel + feed: FeedDescriptor + feedParams?: FeedParams isPageFocused: boolean renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element }) { - const store = useStores() + const {isSandbox, hasSession} = useSession() const pal = usePalette('default') + const {_} = useLingui() const {isDesktop} = useWebMediaQueries() + const queryClient = useQueryClient() + const {openComposer} = useComposerControls() const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll() const {screen, track} = useAnalytics() const headerOffset = useHeaderOffset() const scrollElRef = React.useRef<FlatList>(null) - const {appState} = useAppState({ - onForeground: () => doPoll(true), - }) const isScreenFocused = useIsFocused() - const hasNew = feed.hasNewLatest && !feed.isRefreshing - - React.useEffect(() => { - // called on first load - if (!feed.hasLoaded && isPageFocused) { - feed.setup() - } - }, [isPageFocused, feed]) - - const doPoll = React.useCallback( - (knownActive = false) => { - if ( - (!knownActive && appState !== 'active') || - !isScreenFocused || - !isPageFocused - ) { - return - } - if (feed.isLoading) { - return - } - logger.debug('HomeScreen: Polling for new posts') - feed.checkForLatest() - }, - [appState, isScreenFocused, isPageFocused, feed], - ) + const [hasNew, setHasNew] = React.useState(false) const scrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerOffset}) @@ -81,41 +63,30 @@ export const FeedPage = observer(function FeedPageImpl({ const onSoftReset = React.useCallback(() => { if (isPageFocused) { scrollToTop() - feed.refresh() + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) } - }, [isPageFocused, scrollToTop, feed]) + }, [isPageFocused, scrollToTop, queryClient, feed, setHasNew]) // fires when page within screen is activated/deactivated - // - check for latest React.useEffect(() => { if (!isPageFocused || !isScreenFocused) { return } - - const softResetSub = store.onScreenSoftReset(onSoftReset) - const feedCleanup = feed.registerListeners() - const pollInterval = setInterval(doPoll, POLL_FREQ) - screen('Feed') - logger.debug('HomeScreen: Updating feed') - feed.checkForLatest() - - return () => { - clearInterval(pollInterval) - softResetSub.remove() - feedCleanup() - } - }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused]) + return listenSoftReset(onSoftReset) + }, [onSoftReset, screen, isPageFocused, isScreenFocused]) const onPressCompose = React.useCallback(() => { track('HomeScreen:PressCompose') - store.shell.openComposer({}) - }, [store, track]) + openComposer({}) + }, [openComposer, track]) const onPressLoadLatest = React.useCallback(() => { scrollToTop() - feed.refresh() - }, [feed, scrollToTop]) + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) + }, [scrollToTop, feed, queryClient, setHasNew]) const ListHeaderComponent = React.useCallback(() => { if (isDesktop) { @@ -137,7 +108,7 @@ export const FeedPage = observer(function FeedPageImpl({ style={[pal.text, {fontWeight: 'bold'}]} text={ <> - {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} + {isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} {hasNew && ( <View style={{ @@ -151,35 +122,50 @@ export const FeedPage = observer(function FeedPageImpl({ )} </> } - onPress={() => store.emitScreenSoftReset()} - /> - <TextLink - type="title-lg" - href="/settings/home-feed" - style={{fontWeight: 'bold'}} - accessibilityLabel="Feed Preferences" - accessibilityHint="" - text={ - <FontAwesomeIcon - icon="sliders" - style={pal.textLight as FontAwesomeIconStyle} - /> - } + onPress={emitSoftReset} /> + {hasSession && ( + <TextLink + type="title-lg" + href="/settings/home-feed" + style={{fontWeight: 'bold'}} + accessibilityLabel={_(msg`Feed Preferences`)} + accessibilityHint="" + text={ + <FontAwesomeIcon + icon="sliders" + style={pal.textLight as FontAwesomeIconStyle} + /> + } + /> + )} </View> ) } return <></> - }, [isDesktop, pal, store, hasNew]) + }, [ + isDesktop, + pal.view, + pal.text, + pal.textLight, + hasNew, + _, + isSandbox, + hasSession, + ]) return ( <View testID={testID} style={s.h100pct}> <Feed testID={testID ? `${testID}-feed` : undefined} + enabled={isPageFocused} feed={feed} + feedParams={feedParams} + pollInterval={POLL_FREQ} scrollElRef={scrollElRef} onScroll={onMainScroll} - scrollEventThrottle={100} + onHasNew={setHasNew} + scrollEventThrottle={1} renderEmptyState={renderEmptyState} renderEndOfFeed={renderEndOfFeed} ListHeaderComponent={ListHeaderComponent} @@ -188,18 +174,52 @@ export const FeedPage = observer(function FeedPageImpl({ {(isScrolledDown || hasNew) && ( <LoadLatestBtn onPress={onPressLoadLatest} - label="Load new posts" + label={_(msg`Load new posts`)} showIndicator={hasNew} /> )} - <FAB - testID="composeFAB" - onPress={onPressCompose} - icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} - accessibilityRole="button" - accessibilityLabel="New post" - accessibilityHint="" - /> + + {hasSession && ( + <FAB + testID="composeFAB" + onPress={onPressCompose} + icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + )} </View> ) -}) +} + +function useHeaderOffset() { + const {isDesktop, isTablet} = useWebMediaQueries() + const {fontScale} = useWindowDimensions() + const {hasSession} = useSession() + + if (isDesktop) { + return 0 + } + if (isTablet) { + if (hasSession) { + return 50 + } else { + return 0 + } + } + + if (hasSession) { + const navBarPad = 16 + const navBarText = 21 * fontScale + const tabBarPad = 20 + 3 // nav bar padding + border + const tabBarText = 16 * fontScale + const magic = 7 * fontScale + return navBarPad + navBarText + tabBarPad + tabBarText + magic + } else { + const navBarPad = 16 + const navBarText = 21 * fontScale + const magic = 4 * fontScale + return navBarPad + navBarText + magic + } +} diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index 2c4335dc1..1f2af069b 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -6,43 +6,110 @@ import {RichText} from '../util/text/RichText' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {UserAvatar} from '../util/UserAvatar' -import {observer} from 'mobx-react-lite' -import {FeedSourceModel} from 'state/models/content/feed-source' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' -import {useStores} from 'state/index' import {pluralize} from 'lib/strings/helpers' import {AtUri} from '@atproto/api' 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 {useLingui} from '@lingui/react' +import { + usePinFeedMutation, + UsePreferencesQueryResponse, + usePreferencesQuery, + useSaveFeedMutation, + useRemoveFeedMutation, +} from '#/state/queries/preferences' +import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' -export const FeedSourceCard = observer(function FeedSourceCardImpl({ - item, +export function FeedSourceCard({ + feedUri, style, showSaveBtn = false, showDescription = false, showLikes = false, + LoadingComponent, + pinOnSave = false, }: { - item: FeedSourceModel + feedUri: string style?: StyleProp<ViewStyle> showSaveBtn?: boolean showDescription?: boolean showLikes?: boolean + LoadingComponent?: JSX.Element + pinOnSave?: boolean +}) { + const {data: preferences} = usePreferencesQuery() + const {data: feed} = useFeedSourceInfoQuery({uri: feedUri}) + + if (!feed || !preferences) { + return LoadingComponent ? ( + LoadingComponent + ) : ( + <FeedLoadingPlaceholder style={{flex: 1}} /> + ) + } + + return ( + <FeedSourceCardLoaded + feed={feed} + preferences={preferences} + style={style} + showSaveBtn={showSaveBtn} + showDescription={showDescription} + showLikes={showLikes} + pinOnSave={pinOnSave} + /> + ) +} + +export function FeedSourceCardLoaded({ + feed, + preferences, + style, + showSaveBtn = false, + showDescription = false, + showLikes = false, + pinOnSave = false, +}: { + feed: FeedSourceInfo + preferences: UsePreferencesQueryResponse + style?: StyleProp<ViewStyle> + showSaveBtn?: boolean + showDescription?: boolean + showLikes?: boolean + pinOnSave?: boolean }) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() const navigation = useNavigation<NavigationProp>() + const {openModal} = useModalControls() + + const {isPending: isSavePending, mutateAsync: saveFeed} = + useSaveFeedMutation() + const {isPending: isRemovePending, mutateAsync: removeFeed} = + useRemoveFeedMutation() + const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation() + + const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed.uri)) const onToggleSaved = React.useCallback(async () => { - if (item.isSaved) { - store.shell.openModal({ + // Only feeds can be un/saved, lists are handled elsewhere + if (feed?.type !== 'feed') return + + if (isSaved) { + openModal({ name: 'confirm', - title: 'Remove from my feeds', - message: `Remove ${item.displayName} from my feeds?`, + title: _(msg`Remove from my feeds`), + message: _(msg`Remove ${feed.displayName} from my feeds?`), onPressConfirm: async () => { try { - await item.unsave() + await removeFeed({uri: feed.uri}) + // await item.unsave() Toast.show('Removed from my feeds') } catch (e) { Toast.show('There was an issue contacting your server') @@ -52,58 +119,67 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({ }) } else { try { - await item.save() + if (pinOnSave) { + await pinFeed({uri: feed.uri}) + } else { + await saveFeed({uri: feed.uri}) + } Toast.show('Added to my feeds') } catch (e) { Toast.show('There was an issue contacting your server') logger.error('Failed to save feed', {error: e}) } } - }, [store, item]) + }, [isSaved, openModal, feed, removeFeed, saveFeed, _, pinOnSave, pinFeed]) + + if (!feed || !preferences) return null return ( <Pressable - testID={`feed-${item.displayName}`} + testID={`feed-${feed.displayName}`} accessibilityRole="button" style={[styles.container, pal.border, style]} onPress={() => { - if (item.type === 'feed-generator') { + if (feed.type === 'feed') { navigation.push('ProfileFeed', { - name: item.creatorDid, - rkey: new AtUri(item.uri).rkey, + name: feed.creatorDid, + rkey: new AtUri(feed.uri).rkey, }) - } else if (item.type === 'list') { + } else if (feed.type === 'list') { navigation.push('ProfileList', { - name: item.creatorDid, - rkey: new AtUri(item.uri).rkey, + name: feed.creatorDid, + rkey: new AtUri(feed.uri).rkey, }) } }} - key={item.uri}> + key={feed.uri}> <View style={[styles.headerContainer]}> <View style={[s.mr10]}> - <UserAvatar type="algo" size={36} avatar={item.avatar} /> + <UserAvatar type="algo" size={36} avatar={feed.avatar} /> </View> <View style={[styles.headerTextContainer]}> <Text style={[pal.text, s.bold]} numberOfLines={3}> - {item.displayName} + {feed.displayName} </Text> <Text style={[pal.textLight]} numberOfLines={3}> - by {sanitizeHandle(item.creatorHandle, '@')} + {feed.type === 'feed' ? 'Feed' : 'List'} by{' '} + {sanitizeHandle(feed.creatorHandle, '@')} </Text> </View> - {showSaveBtn && ( + + {showSaveBtn && feed.type === 'feed' && ( <View> <Pressable + disabled={isSavePending || isPinPending || isRemovePending} accessibilityRole="button" accessibilityLabel={ - item.isSaved ? 'Remove from my feeds' : 'Add to my feeds' + isSaved ? 'Remove from my feeds' : 'Add to my feeds' } accessibilityHint="" onPress={onToggleSaved} hitSlop={15} style={styles.btn}> - {item.isSaved ? ( + {isSaved ? ( <FontAwesomeIcon icon={['far', 'trash-can']} size={19} @@ -121,23 +197,23 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({ )} </View> - {showDescription && item.descriptionRT ? ( + {showDescription && feed.description ? ( <RichText style={[pal.textLight, styles.description]} - richText={item.descriptionRT} + richText={feed.description} numberOfLines={3} /> ) : null} - {showLikes ? ( + {showLikes && feed.type === 'feed' ? ( <Text type="sm-medium" style={[pal.text, pal.textLight]}> - Liked by {item.likeCount || 0}{' '} - {pluralize(item.likeCount || 0, 'user')} + Liked by {feed.likeCount || 0}{' '} + {pluralize(feed.likeCount || 0, 'user')} </Text> ) : null} </Pressable> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx new file mode 100644 index 000000000..618f4e5cd --- /dev/null +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -0,0 +1,222 @@ +import React, {MutableRefObject} from 'react' +import { + Dimensions, + RefreshControl, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' +import {useQueryClient} from '@tanstack/react-query' +import {FlatList} from '../util/Views' +import {FeedSourceCardLoaded} from './FeedSourceCard' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' +import {Text} from '../util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {useProfileFeedgensQuery, RQKEY} from '#/state/queries/profile-feedgens' +import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll' +import {logger} from '#/logger' +import {Trans} from '@lingui/macro' +import {cleanError} from '#/lib/strings/errors' +import {useAnimatedScrollHandler} from 'react-native-reanimated' +import {useTheme} from '#/lib/ThemeContext' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {hydrateFeedGenerator} from '#/state/queries/feed' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' + +const LOADING = {_reactKey: '__loading__'} +const EMPTY = {_reactKey: '__empty__'} +const ERROR_ITEM = {_reactKey: '__error__'} +const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} + +interface SectionRef { + scrollToTop: () => void +} + +interface ProfileFeedgensProps { + did: string + scrollElRef: MutableRefObject<FlatList<any> | null> + onScroll?: OnScrollHandler + scrollEventThrottle?: number + headerOffset: number + enabled?: boolean + style?: StyleProp<ViewStyle> + testID?: string +} + +export const ProfileFeedgens = React.forwardRef< + SectionRef, + ProfileFeedgensProps +>(function ProfileFeedgensImpl( + { + did, + scrollElRef, + onScroll, + scrollEventThrottle, + headerOffset, + enabled, + style, + testID, + }, + ref, +) { + const pal = usePalette('default') + const theme = useTheme() + const [isPTRing, setIsPTRing] = React.useState(false) + const opts = React.useMemo(() => ({enabled}), [enabled]) + const { + data, + isFetching, + isFetched, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = useProfileFeedgensQuery(did, opts) + const isEmpty = !isFetching && !data?.pages[0]?.feeds.length + const {data: preferences} = usePreferencesQuery() + + const items = React.useMemo(() => { + let items: any[] = [] + if (isError && isEmpty) { + items = items.concat([ERROR_ITEM]) + } + if (!isFetched && isFetching) { + items = items.concat([LOADING]) + } else if (isEmpty) { + items = items.concat([EMPTY]) + } else if (data?.pages) { + for (const page of data?.pages) { + items = items.concat(page.feeds.map(feed => hydrateFeedGenerator(feed))) + } + } + if (isError && !isEmpty) { + items = items.concat([LOAD_MORE_ERROR_ITEM]) + } + return items + }, [isError, isEmpty, isFetched, isFetching, data]) + + // events + // = + + const queryClient = useQueryClient() + + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerOffset}) + queryClient.invalidateQueries({queryKey: RQKEY(did)}) + }, [scrollElRef, queryClient, headerOffset, did]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh feeds', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) + + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more feeds', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const onPressRetryLoadMore = React.useCallback(() => { + fetchNextPage() + }, [fetchNextPage]) + + // rendering + // = + + const renderItemInner = React.useCallback( + ({item}: {item: any}) => { + if (item === EMPTY) { + return ( + <View + testID="listsEmpty" + style={[{padding: 18, borderTopWidth: 1}, pal.border]}> + <Text style={pal.textLight}> + <Trans>You have no feeds.</Trans> + </Text> + </View> + ) + } else if (item === ERROR_ITEM) { + return ( + <ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} /> + ) + } else if (item === LOAD_MORE_ERROR_ITEM) { + return ( + <LoadMoreRetryBtn + label="There was an issue fetching your lists. Tap here to try again." + onPress={onPressRetryLoadMore} + /> + ) + } else if (item === LOADING) { + return <FeedLoadingPlaceholder /> + } + if (preferences) { + return ( + <FeedSourceCardLoaded + feed={item} + preferences={preferences} + style={styles.item} + showLikes + /> + ) + } + return null + }, + [error, refetch, onPressRetryLoadMore, pal, preferences], + ) + + const scrollHandler = useAnimatedScrollHandler(onScroll || {}) + return ( + <View testID={testID} style={style}> + <FlatList + testID={testID ? `${testID}-flatlist` : undefined} + ref={scrollElRef} + data={items} + keyExtractor={(item: any) => item._reactKey || item.uri} + renderItem={renderItemInner} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + progressViewOffset={headerOffset} + /> + } + contentContainerStyle={{ + minHeight: Dimensions.get('window').height * 1.5, + }} + style={{paddingTop: headerOffset}} + onScroll={onScroll != null ? scrollHandler : undefined} + scrollEventThrottle={scrollEventThrottle} + indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} + removeClippedSubviews={true} + contentOffset={{x: 0, y: headerOffset * -1}} + // @ts-ignore our .web version only -prf + desktopFixedHeight + onEndReached={onEndReached} + /> + </View> + ) +}) + +const styles = StyleSheet.create({ + item: { + paddingHorizontal: 18, + }, +}) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx index bb006d506..c806bc6a6 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx @@ -5,10 +5,10 @@ * LICENSE file in the root directory of this source tree. * */ - -import {createHitslop} from 'lib/constants' import React from 'react' +import {createHitslop} from 'lib/constants' import {SafeAreaView, Text, TouchableOpacity, StyleSheet} from 'react-native' +import {t} from '@lingui/macro' type Props = { onRequestClose: () => void @@ -23,7 +23,7 @@ const ImageDefaultHeader = ({onRequestClose}: Props) => ( onPress={onRequestClose} hitSlop={HIT_SLOP} accessibilityRole="button" - accessibilityLabel="Close image" + accessibilityLabel={t`Close image`} accessibilityHint="Closes viewer for header image" onAccessibilityEscape={onRequestClose}> <Text style={styles.closeText}>✕</Text> 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 7c7ad0616..ea740ec91 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -315,7 +315,6 @@ const ImageItem = ({ <GestureDetector gesture={composedGesture}> <AnimatedImage contentFit="contain" - // NOTE: Don't pass imageSrc={imageSrc} or MobX will break. source={{uri: imageSrc.uri}} style={[styles.image, animatedStyle]} accessibilityLabel={imageSrc.alt} diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx index f73f355ac..2b0b0b149 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -139,7 +139,6 @@ const ImageItem = ({imageSrc, onTap, onZoom, onRequestClose}: Props) => { {(!loaded || !imageDimensions) && <ImageLoading />} <AnimatedImage contentFit="contain" - // NOTE: Don't pass imageSrc={imageSrc} or MobX will break. source={{uri: imageSrc.uri}} style={[styles.image, animatedStyle]} accessibilityLabel={imageSrc.alt} diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index 92c30f491..8a18df33f 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -1,10 +1,7 @@ import React from 'react' import {Pressable, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import ImageView from './ImageViewing' -import {useStores} from 'state/index' -import * as models from 'state/models/ui/shell' import {shareImageModal, saveImageToMediaLibrary} from 'lib/media/manip' import * as Toast from '../util/Toast' import {Text} from '../util/text/Text' @@ -12,28 +9,35 @@ import {s, colors} from 'lib/styles' import {Button} from '../util/forms/Button' import {isIOS} from 'platform/detection' import * as MediaLibrary from 'expo-media-library' +import { + useLightbox, + useLightboxControls, + ProfileImageLightbox, + ImagesLightbox, +} from '#/state/lightbox' -export const Lightbox = observer(function Lightbox() { - const store = useStores() +export function Lightbox() { + const {activeLightbox} = useLightbox() + const {closeLightbox} = useLightboxControls() const onClose = React.useCallback(() => { - store.shell.closeLightbox() - }, [store]) + closeLightbox() + }, [closeLightbox]) - if (!store.shell.activeLightbox) { + if (!activeLightbox) { return null - } else if (store.shell.activeLightbox.name === 'profile-image') { - const opts = store.shell.activeLightbox as models.ProfileImageLightbox + } else if (activeLightbox.name === 'profile-image') { + const opts = activeLightbox as ProfileImageLightbox return ( <ImageView - images={[{uri: opts.profileView.avatar || ''}]} + images={[{uri: opts.profile.avatar || ''}]} initialImageIndex={0} visible onRequestClose={onClose} FooterComponent={LightboxFooter} /> ) - } else if (store.shell.activeLightbox.name === 'images') { - const opts = store.shell.activeLightbox as models.ImagesLightbox + } else if (activeLightbox.name === 'images') { + const opts = activeLightbox as ImagesLightbox return ( <ImageView images={opts.images.map(img => ({...img}))} @@ -46,14 +50,10 @@ export const Lightbox = observer(function Lightbox() { } else { return null } -}) +} -const LightboxFooter = observer(function LightboxFooter({ - imageIndex, -}: { - imageIndex: number -}) { - const store = useStores() +function LightboxFooter({imageIndex}: {imageIndex: number}) { + const {activeLightbox} = useLightbox() const [isAltExpanded, setAltExpanded] = React.useState(false) const [permissionResponse, requestPermission] = MediaLibrary.usePermissions() @@ -81,7 +81,7 @@ const LightboxFooter = observer(function LightboxFooter({ [permissionResponse, requestPermission], ) - const lightbox = store.shell.activeLightbox + const lightbox = activeLightbox if (!lightbox) { return null } @@ -89,12 +89,12 @@ const LightboxFooter = observer(function LightboxFooter({ let altText = '' let uri = '' if (lightbox.name === 'images') { - const opts = lightbox as models.ImagesLightbox + const opts = lightbox as ImagesLightbox uri = opts.images[imageIndex].uri altText = opts.images[imageIndex].alt || '' } else if (lightbox.name === 'profile-image') { - const opts = lightbox as models.ProfileImageLightbox - uri = opts.profileView.avatar || '' + const opts = lightbox as ProfileImageLightbox + uri = opts.profile.avatar || '' } return ( @@ -132,7 +132,7 @@ const LightboxFooter = observer(function LightboxFooter({ </View> </View> ) -}) +} const styles = StyleSheet.create({ footer: { diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index ddf965f42..45e1fa5a3 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -7,39 +7,42 @@ import { View, Pressable, } from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useStores} from 'state/index' -import * as models from 'state/models/ui/shell' import {colors, s} from 'lib/styles' import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader' import {Text} from '../util/text/Text' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import { + useLightbox, + useLightboxControls, + ImagesLightbox, + ProfileImageLightbox, +} from '#/state/lightbox' interface Img { uri: string alt?: string } -export const Lightbox = observer(function Lightbox() { - const store = useStores() - - const onClose = useCallback(() => store.shell.closeLightbox(), [store.shell]) +export function Lightbox() { + const {activeLightbox} = useLightbox() + const {closeLightbox} = useLightboxControls() - if (!store.shell.isLightboxActive) { + if (!activeLightbox) { return null } - const activeLightbox = store.shell.activeLightbox const initialIndex = - activeLightbox instanceof models.ImagesLightbox ? activeLightbox.index : 0 + activeLightbox instanceof ImagesLightbox ? activeLightbox.index : 0 let imgs: Img[] | undefined - if (activeLightbox instanceof models.ProfileImageLightbox) { + if (activeLightbox instanceof ProfileImageLightbox) { const opts = activeLightbox - if (opts.profileView.avatar) { - imgs = [{uri: opts.profileView.avatar}] + if (opts.profile.avatar) { + imgs = [{uri: opts.profile.avatar}] } - } else if (activeLightbox instanceof models.ImagesLightbox) { + } else if (activeLightbox instanceof ImagesLightbox) { const opts = activeLightbox imgs = opts.images } @@ -49,9 +52,13 @@ export const Lightbox = observer(function Lightbox() { } return ( - <LightboxInner imgs={imgs} initialIndex={initialIndex} onClose={onClose} /> + <LightboxInner + imgs={imgs} + initialIndex={initialIndex} + onClose={closeLightbox} + /> ) -}) +} function LightboxInner({ imgs, @@ -62,6 +69,7 @@ function LightboxInner({ initialIndex: number onClose: () => void }) { + const {_} = useLingui() const [index, setIndex] = useState<number>(initialIndex) const [isAltExpanded, setAltExpanded] = useState(false) @@ -101,7 +109,7 @@ function LightboxInner({ <TouchableWithoutFeedback onPress={onClose} accessibilityRole="button" - accessibilityLabel="Close image viewer" + accessibilityLabel={_(msg`Close image viewer`)} accessibilityHint="Exits image view" onAccessibilityEscape={onClose}> <View style={styles.imageCenterer}> @@ -117,7 +125,7 @@ function LightboxInner({ onPress={onPressLeft} style={[styles.btn, styles.leftBtn]} accessibilityRole="button" - accessibilityLabel="Previous image" + accessibilityLabel={_(msg`Previous image`)} accessibilityHint=""> <FontAwesomeIcon icon="angle-left" @@ -131,7 +139,7 @@ function LightboxInner({ onPress={onPressRight} style={[styles.btn, styles.rightBtn]} accessibilityRole="button" - accessibilityLabel="Next image" + accessibilityLabel={_(msg`Next image`)} accessibilityHint=""> <FontAwesomeIcon icon="angle-right" @@ -145,7 +153,7 @@ function LightboxInner({ {imgs[index].alt ? ( <View style={styles.footer}> <Pressable - accessibilityLabel="Expand alt text" + accessibilityLabel={_(msg`Expand alt text`)} accessibilityHint="If alt text is long, toggles alt text expanded state" onPress={() => { setAltExpanded(!isAltExpanded) diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx index a481902d8..774e9e916 100644 --- a/src/view/com/lists/ListCard.tsx +++ b/src/view/com/lists/ListCard.tsx @@ -7,7 +7,7 @@ import {RichText as RichTextCom} from '../util/text/RichText' import {UserAvatar} from '../util/UserAvatar' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' +import {useSession} from '#/state/session' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' @@ -28,7 +28,7 @@ export const ListCard = ({ style?: StyleProp<ViewStyle> }) => { const pal = usePalette('default') - const store = useStores() + const {currentAccount} = useSession() const rkey = React.useMemo(() => { try { @@ -80,7 +80,7 @@ export const ListCard = ({ {list.purpose === 'app.bsky.graph.defs#modlist' && 'Moderation list '} by{' '} - {list.creator.did === store.me.did + {list.creator.did === currentAccount?.did ? 'you' : sanitizeHandle(list.creator.handle, '@')} </Text> diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListMembers.tsx index 192cdd9d3..e6afb3d3c 100644 --- a/src/view/com/lists/ListItems.tsx +++ b/src/view/com/lists/ListMembers.tsx @@ -1,6 +1,7 @@ import React, {MutableRefObject} from 'react' import { ActivityIndicator, + Dimensions, RefreshControl, StyleProp, View, @@ -8,27 +9,28 @@ import { } from 'react-native' import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' import {FlatList} from '../util/Views' -import {observer} from 'mobx-react-lite' import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {ProfileCard} from '../profile/ProfileCard' import {Button} from '../util/forms/Button' -import {ListModel} from 'state/models/content/list' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {s} from 'lib/styles' -import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {useListMembersQuery} from '#/state/queries/list-members' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {logger} from '#/logger' +import {useModalControls} from '#/state/modals' +import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' +import {useSession} from '#/state/session' +import {cleanError} from '#/lib/strings/errors' const LOADING_ITEM = {_reactKey: '__loading__'} const EMPTY_ITEM = {_reactKey: '__empty__'} const ERROR_ITEM = {_reactKey: '__error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -export const ListItems = observer(function ListItemsImpl({ +export function ListMembers({ list, style, scrollElRef, @@ -41,10 +43,10 @@ export const ListItems = observer(function ListItemsImpl({ headerOffset = 0, desktopFixedHeightOffset, }: { - list: ListModel + list: string style?: StyleProp<ViewStyle> scrollElRef?: MutableRefObject<FlatList<any> | null> - onScroll?: OnScrollCb + onScroll: OnScrollHandler onPressTryAgain?: () => void renderHeader: () => JSX.Element renderEmptyState: () => JSX.Element @@ -54,37 +56,47 @@ export const ListItems = observer(function ListItemsImpl({ desktopFixedHeightOffset?: number }) { const pal = usePalette('default') - const store = useStores() const {track} = useAnalytics() const [isRefreshing, setIsRefreshing] = React.useState(false) const {isMobile} = useWebMediaQueries() + const {openModal} = useModalControls() + const {currentAccount} = useSession() - const data = React.useMemo(() => { + const { + data, + isFetching, + isFetched, + isError, + error, + refetch, + fetchNextPage, + hasNextPage, + } = useListMembersQuery(list) + const isEmpty = !isFetching && !data?.pages[0].items.length + const isOwner = + currentAccount && data?.pages[0].list.creator.did === currentAccount.did + + const items = React.useMemo(() => { let items: any[] = [] - if (list.hasLoaded) { - if (list.hasError) { + if (isFetched) { + if (isEmpty && isError) { items = items.concat([ERROR_ITEM]) } - if (list.isEmpty) { + if (isEmpty) { items = items.concat([EMPTY_ITEM]) - } else { - items = items.concat(list.items) + } else if (data) { + for (const page of data.pages) { + items = items.concat(page.items) + } } - if (list.loadMoreError) { + if (!isEmpty && isError) { items = items.concat([LOAD_MORE_ERROR_ITEM]) } - } else if (list.isLoading) { + } else if (isFetching) { items = items.concat([LOADING_ITEM]) } return items - }, [ - list.hasError, - list.hasLoaded, - list.isLoading, - list.isEmpty, - list.items, - list.loadMoreError, - ]) + }, [isFetched, isEmpty, isError, data, isFetching]) // events // = @@ -93,45 +105,36 @@ export const ListItems = observer(function ListItemsImpl({ track('Lists:onRefresh') setIsRefreshing(true) try { - await list.refresh() + await refetch() } catch (err) { logger.error('Failed to refresh lists', {error: err}) } setIsRefreshing(false) - }, [list, track, setIsRefreshing]) + }, [refetch, track, setIsRefreshing]) const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return track('Lists:onEndReached') try { - await list.loadMore() + await fetchNextPage() } catch (err) { logger.error('Failed to load more lists', {error: err}) } - }, [list, track]) + }, [isFetching, hasNextPage, isError, fetchNextPage, track]) const onPressRetryLoadMore = React.useCallback(() => { - list.retryLoadMore() - }, [list]) + fetchNextPage() + }, [fetchNextPage]) const onPressEditMembership = React.useCallback( (profile: AppBskyActorDefs.ProfileViewBasic) => { - store.shell.openModal({ + openModal({ name: 'user-add-remove-lists', subject: profile.did, displayName: profile.displayName || profile.handle, - onAdd(listUri: string) { - if (listUri === list.uri) { - list.cacheAddMember(profile) - } - }, - onRemove(listUri: string) { - if (listUri === list.uri) { - list.cacheRemoveMember(profile) - } - }, }) }, - [store, list], + [openModal], ) // rendering @@ -139,7 +142,7 @@ export const ListItems = observer(function ListItemsImpl({ const renderMemberButton = React.useCallback( (profile: AppBskyActorDefs.ProfileViewBasic) => { - if (!list.isOwner) { + if (!isOwner) { return null } return ( @@ -151,7 +154,7 @@ export const ListItems = observer(function ListItemsImpl({ /> ) }, - [list, onPressEditMembership], + [isOwner, onPressEditMembership], ) const renderItem = React.useCallback( @@ -161,7 +164,7 @@ export const ListItems = observer(function ListItemsImpl({ } else if (item === ERROR_ITEM) { return ( <ErrorMessage - message={list.error} + message={cleanError(error)} onPressTryAgain={onPressTryAgain} /> ) @@ -189,7 +192,7 @@ export const ListItems = observer(function ListItemsImpl({ [ renderMemberButton, renderEmptyState, - list.error, + error, onPressTryAgain, onPressRetryLoadMore, isMobile, @@ -199,19 +202,20 @@ export const ListItems = observer(function ListItemsImpl({ const Footer = React.useCallback( () => ( <View style={{paddingTop: 20, paddingBottom: 200}}> - {list.isLoading && <ActivityIndicator />} + {isFetching && <ActivityIndicator />} </View> ), - [list.isLoading], + [isFetching], ) + const scrollHandler = useAnimatedScrollHandler(onScroll) return ( <View testID={testID} style={style}> <FlatList testID={testID ? `${testID}-flatlist` : undefined} ref={scrollElRef} - data={data} - keyExtractor={(item: any) => item._reactKey} + data={items} + keyExtractor={(item: any) => item.subject?.did || item._reactKey} renderItem={renderItem} ListHeaderComponent={renderHeader} ListFooterComponent={Footer} @@ -224,9 +228,11 @@ export const ListItems = observer(function ListItemsImpl({ progressViewOffset={headerOffset} /> } - contentContainerStyle={s.contentContainer} + contentContainerStyle={{ + minHeight: Dimensions.get('window').height * 1.5, + }} style={{paddingTop: headerOffset}} - onScroll={onScroll} + onScroll={scrollHandler} onEndReached={onEndReached} onEndReachedThreshold={0.6} scrollEventThrottle={scrollEventThrottle} @@ -237,4 +243,4 @@ export const ListItems = observer(function ListItemsImpl({ /> </View> ) -}) +} diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/MyLists.tsx index 8c6510886..2c080582e 100644 --- a/src/view/com/lists/ListsList.tsx +++ b/src/view/com/lists/MyLists.tsx @@ -8,94 +8,71 @@ import { View, ViewStyle, } from 'react-native' -import {observer} from 'mobx-react-lite' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' import {ListCard} from './ListCard' +import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists' import {ErrorMessage} from '../util/error/ErrorMessage' -import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {Text} from '../util/text/Text' -import {ListsListModel} from 'state/models/lists/lists-list' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {FlatList} from '../util/Views' import {s} from 'lib/styles' import {logger} from '#/logger' +import {Trans} from '@lingui/macro' +import {cleanError} from '#/lib/strings/errors' const LOADING = {_reactKey: '__loading__'} const EMPTY = {_reactKey: '__empty__'} const ERROR_ITEM = {_reactKey: '__error__'} -const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -export const ListsList = observer(function ListsListImpl({ - listsList, +export function MyLists({ + filter, inline, style, - onPressTryAgain, renderItem, testID, }: { - listsList: ListsListModel + filter: MyListsFilter inline?: boolean style?: StyleProp<ViewStyle> - onPressTryAgain?: () => void renderItem?: (list: GraphDefs.ListView, index: number) => JSX.Element testID?: string }) { const pal = usePalette('default') const {track} = useAnalytics() - const [isRefreshing, setIsRefreshing] = React.useState(false) + const [isPTRing, setIsPTRing] = React.useState(false) + const {data, isFetching, isFetched, isError, error, refetch} = + useMyListsQuery(filter) + const isEmpty = !isFetching && !data?.length - const data = React.useMemo(() => { + const items = React.useMemo(() => { let items: any[] = [] - if (listsList.hasError) { + if (isError && isEmpty) { items = items.concat([ERROR_ITEM]) } - if (!listsList.hasLoaded && listsList.isLoading) { + if (!isFetched && isFetching) { items = items.concat([LOADING]) - } else if (listsList.isEmpty) { + } else if (isEmpty) { items = items.concat([EMPTY]) } else { - items = items.concat(listsList.lists) - } - if (listsList.loadMoreError) { - items = items.concat([LOAD_MORE_ERROR_ITEM]) + items = items.concat(data) } return items - }, [ - listsList.hasError, - listsList.hasLoaded, - listsList.isLoading, - listsList.lists, - listsList.isEmpty, - listsList.loadMoreError, - ]) + }, [isError, isEmpty, isFetched, isFetching, data]) // events // = const onRefresh = React.useCallback(async () => { track('Lists:onRefresh') - setIsRefreshing(true) + setIsPTRing(true) try { - await listsList.refresh() + await refetch() } catch (err) { logger.error('Failed to refresh lists', {error: err}) } - setIsRefreshing(false) - }, [listsList, track, setIsRefreshing]) - - const onEndReached = React.useCallback(async () => { - track('Lists:onEndReached') - try { - await listsList.loadMore() - } catch (err) { - logger.error('Failed to load more lists', {error: err}) - } - }, [listsList, track]) - - const onPressRetryLoadMore = React.useCallback(() => { - listsList.retryLoadMore() - }, [listsList]) + setIsPTRing(false) + }, [refetch, track, setIsPTRing]) // rendering // = @@ -107,21 +84,16 @@ export const ListsList = observer(function ListsListImpl({ <View testID="listsEmpty" style={[{padding: 18, borderTopWidth: 1}, pal.border]}> - <Text style={pal.textLight}>You have no lists.</Text> + <Text style={pal.textLight}> + <Trans>You have no lists.</Trans> + </Text> </View> ) } else if (item === ERROR_ITEM) { return ( <ErrorMessage - message={listsList.error} - onPressTryAgain={onPressTryAgain} - /> - ) - } else if (item === LOAD_MORE_ERROR_ITEM) { - return ( - <LoadMoreRetryBtn - label="There was an issue fetching your lists. Tap here to try again." - onPress={onPressRetryLoadMore} + message={cleanError(error)} + onPressTryAgain={onRefresh} /> ) } else if (item === LOADING) { @@ -141,29 +113,27 @@ export const ListsList = observer(function ListsListImpl({ /> ) }, - [listsList, onPressTryAgain, onPressRetryLoadMore, renderItem, pal], + [error, onRefresh, renderItem, pal], ) const FlatListCom = inline ? RNFlatList : FlatList return ( <View testID={testID} style={style}> - {data.length > 0 && ( + {items.length > 0 && ( <FlatListCom testID={testID ? `${testID}-flatlist` : undefined} - data={data} + data={items} keyExtractor={(item: any) => item._reactKey} renderItem={renderItemInner} refreshControl={ <RefreshControl - refreshing={isRefreshing} + refreshing={isPTRing} onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} /> } contentContainerStyle={[s.contentContainer]} - onEndReached={onEndReached} - onEndReachedThreshold={0.6} removeClippedSubviews={true} // @ts-ignore our .web version only -prf desktopFixedHeight @@ -171,7 +141,7 @@ export const ListsList = observer(function ListsListImpl({ )} </View> ) -}) +} const styles = StyleSheet.create({ item: { diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx new file mode 100644 index 000000000..95cf8fde6 --- /dev/null +++ b/src/view/com/lists/ProfileLists.tsx @@ -0,0 +1,226 @@ +import React, {MutableRefObject} from 'react' +import { + Dimensions, + RefreshControl, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' +import {useQueryClient} from '@tanstack/react-query' +import {FlatList} from '../util/Views' +import {ListCard} from './ListCard' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' +import {Text} from '../util/text/Text' +import {useAnalytics} from 'lib/analytics/analytics' +import {usePalette} from 'lib/hooks/usePalette' +import {useProfileListsQuery, RQKEY} from '#/state/queries/profile-lists' +import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll' +import {logger} from '#/logger' +import {Trans} from '@lingui/macro' +import {cleanError} from '#/lib/strings/errors' +import {useAnimatedScrollHandler} from 'react-native-reanimated' +import {useTheme} from '#/lib/ThemeContext' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' + +const LOADING = {_reactKey: '__loading__'} +const EMPTY = {_reactKey: '__empty__'} +const ERROR_ITEM = {_reactKey: '__error__'} +const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} + +interface SectionRef { + scrollToTop: () => void +} + +interface ProfileListsProps { + did: string + scrollElRef: MutableRefObject<FlatList<any> | null> + onScroll?: OnScrollHandler + scrollEventThrottle?: number + headerOffset: number + enabled?: boolean + style?: StyleProp<ViewStyle> + testID?: string +} + +export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( + function ProfileListsImpl( + { + did, + scrollElRef, + onScroll, + scrollEventThrottle, + headerOffset, + enabled, + style, + testID, + }, + ref, + ) { + const pal = usePalette('default') + const theme = useTheme() + const {track} = useAnalytics() + const [isPTRing, setIsPTRing] = React.useState(false) + const opts = React.useMemo(() => ({enabled}), [enabled]) + const { + data, + isFetching, + isFetched, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = useProfileListsQuery(did, opts) + const isEmpty = !isFetching && !data?.pages[0]?.lists.length + + const items = React.useMemo(() => { + let items: any[] = [] + if (isError && isEmpty) { + items = items.concat([ERROR_ITEM]) + } + if (!isFetched && isFetching) { + items = items.concat([LOADING]) + } else if (isEmpty) { + items = items.concat([EMPTY]) + } else if (data?.pages) { + for (const page of data?.pages) { + items = items.concat( + page.lists.map(l => ({ + ...l, + _reactKey: l.uri, + })), + ) + } + } + if (isError && !isEmpty) { + items = items.concat([LOAD_MORE_ERROR_ITEM]) + } + return items + }, [isError, isEmpty, isFetched, isFetching, data]) + + // events + // = + + const queryClient = useQueryClient() + + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerOffset}) + queryClient.invalidateQueries({queryKey: RQKEY(did)}) + }, [scrollElRef, queryClient, headerOffset, did]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const onRefresh = React.useCallback(async () => { + track('Lists:onRefresh') + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh lists', {error: err}) + } + setIsPTRing(false) + }, [refetch, track, setIsPTRing]) + + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + + track('Lists:onEndReached') + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more lists', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage, track]) + + const onPressRetryLoadMore = React.useCallback(() => { + fetchNextPage() + }, [fetchNextPage]) + + // rendering + // = + + const renderItemInner = React.useCallback( + ({item}: {item: any}) => { + if (item === EMPTY) { + return ( + <View + testID="listsEmpty" + style={[{padding: 18, borderTopWidth: 1}, pal.border]}> + <Text style={pal.textLight}> + <Trans>You have no lists.</Trans> + </Text> + </View> + ) + } else if (item === ERROR_ITEM) { + return ( + <ErrorMessage + message={cleanError(error)} + onPressTryAgain={refetch} + /> + ) + } else if (item === LOAD_MORE_ERROR_ITEM) { + return ( + <LoadMoreRetryBtn + label="There was an issue fetching your lists. Tap here to try again." + onPress={onPressRetryLoadMore} + /> + ) + } else if (item === LOADING) { + return <FeedLoadingPlaceholder /> + } + return ( + <ListCard + list={item} + testID={`list-${item.name}`} + style={styles.item} + /> + ) + }, + [error, refetch, onPressRetryLoadMore, pal], + ) + + const scrollHandler = useAnimatedScrollHandler(onScroll || {}) + return ( + <View testID={testID} style={style}> + <FlatList + testID={testID ? `${testID}-flatlist` : undefined} + ref={scrollElRef} + data={items} + keyExtractor={(item: any) => item._reactKey} + renderItem={renderItemInner} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + progressViewOffset={headerOffset} + /> + } + contentContainerStyle={{ + minHeight: Dimensions.get('window').height * 1.5, + }} + style={{paddingTop: headerOffset}} + onScroll={onScroll != null ? scrollHandler : undefined} + scrollEventThrottle={scrollEventThrottle} + indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} + removeClippedSubviews={true} + contentOffset={{x: 0, y: headerOffset * -1}} + // @ts-ignore our .web version only -prf + desktopFixedHeight + onEndReached={onEndReached} + /> + </View> + ) + }, +) + +const styles = StyleSheet.create({ + item: { + paddingHorizontal: 18, + }, +}) diff --git a/src/view/com/modals/AddAppPasswords.tsx b/src/view/com/modals/AddAppPasswords.tsx index 29763620f..812a36f45 100644 --- a/src/view/com/modals/AddAppPasswords.tsx +++ b/src/view/com/modals/AddAppPasswords.tsx @@ -3,7 +3,6 @@ import {StyleSheet, TextInput, View, TouchableOpacity} from 'react-native' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import {s} from 'lib/styles' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {isNative} from 'platform/detection' import { @@ -13,6 +12,13 @@ import { import Clipboard from '@react-native-clipboard/clipboard' import * as Toast from '../util/Toast' import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import { + useAppPasswordsQuery, + useAppPasswordCreateMutation, +} from '#/state/queries/app-passwords' export const snapPoints = ['70%'] @@ -53,7 +59,10 @@ const shadesOfBlue: string[] = [ export function Component({}: {}) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const {closeModal} = useModalControls() + const {data: passwords} = useAppPasswordsQuery() + const createMutation = useAppPasswordCreateMutation() const [name, setName] = useState( shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)], ) @@ -69,33 +78,42 @@ export function Component({}: {}) { }, [appPassword]) const onDone = React.useCallback(() => { - store.shell.closeModal() - }, [store]) + closeModal() + }, [closeModal]) const createAppPassword = async () => { // 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.', + 'times', ) return } // 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.') + Toast.show( + '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') return } try { - const newPassword = await store.me.createAppPassword(name) + const newPassword = await createMutation.mutateAsync({name}) if (newPassword) { setAppPassword(newPassword.password) } else { - Toast.show('Failed to create app password.') + Toast.show('Failed to create app password.', 'times') // TODO: better error handling (?) } } catch (e) { - Toast.show('Failed to create app password.') + Toast.show('Failed to create app password.', 'times') logger.error('Failed to create app password', {error: e}) } } @@ -119,15 +137,19 @@ export function Component({}: {}) { <View> {!appPassword ? ( <Text type="lg" style={[pal.text]}> - Please enter a unique name for this App Password or use our randomly - generated one. + <Trans> + Please enter a unique name for this App Password or use our + randomly generated one. + </Trans> </Text> ) : ( <Text type="lg" style={[pal.text]}> - <Text type="lg-bold" style={[pal.text]}> - Here is your app password. - </Text>{' '} - Use this to sign into the other app along with your handle. + <Text type="lg-bold" style={[pal.text, s.mr5]}> + <Trans>Here is your app password.</Trans> + </Text> + <Trans> + Use this to sign into the other app along with your handle. + </Trans> </Text> )} {!appPassword ? ( @@ -152,7 +174,7 @@ export function Component({}: {}) { returnKeyType="done" onEndEditing={createAppPassword} accessible={true} - accessibilityLabel="Name" + accessibilityLabel={_(msg`Name`)} accessibilityHint="Input name for app password" /> </View> @@ -161,13 +183,15 @@ export function Component({}: {}) { style={[pal.border, styles.passwordContainer, pal.btn]} onPress={onCopy} accessibilityRole="button" - accessibilityLabel="Copy" + accessibilityLabel={_(msg`Copy`)} accessibilityHint="Copies app password"> <Text type="2xl-bold" style={[pal.text]}> {appPassword} </Text> {wasCopied ? ( - <Text style={[pal.textLight]}>Copied</Text> + <Text style={[pal.textLight]}> + <Trans>Copied</Trans> + </Text> ) : ( <FontAwesomeIcon icon={['far', 'clone']} @@ -180,14 +204,18 @@ export function Component({}: {}) { </View> {appPassword ? ( <Text type="lg" style={[pal.textLight, s.mb10]}> - For security reasons, you won't be able to view this again. If you - lose this password, you'll need to generate a new one. + <Trans> + For security reasons, you won't be able to view this again. If you + lose this password, you'll need to generate a new one. + </Trans> </Text> ) : ( <Text type="xs" style={[pal.textLight, s.mb10, s.mt2]}> - Can only contain letters, numbers, spaces, dashes, and underscores. - Must be at least 4 characters long, but no more than 32 characters - long. + <Trans> + Can only contain letters, numbers, spaces, dashes, and underscores. + Must be at least 4 characters long, but no more than 32 characters + long. + </Trans> </Text> )} <View style={styles.btnContainer}> diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx index c084e84a3..80130f43a 100644 --- a/src/view/com/modals/AltImage.tsx +++ b/src/view/com/modals/AltImage.tsx @@ -17,9 +17,11 @@ 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 {useStores} from 'state/index' import {isAndroid, 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'] @@ -29,10 +31,11 @@ interface Props { export function Component({image}: Props) { const pal = usePalette('default') - const store = useStores() const theme = useTheme() + const {_} = useLingui() const [altText, setAltText] = useState(image.altText) const windim = useWindowDimensions() + const {closeModal} = useModalControls() const imageStyles = useMemo<ImageStyle>(() => { const maxWidth = isWeb ? 450 : windim.width @@ -53,11 +56,11 @@ export function Component({image}: Props) { const onPressSave = useCallback(() => { image.setAltText(altText) - store.shell.closeModal() - }, [store, image, altText]) + closeModal() + }, [closeModal, image, altText]) const onPressCancel = () => { - store.shell.closeModal() + closeModal() } return ( @@ -90,7 +93,7 @@ export function Component({image}: Props) { placeholderTextColor={pal.colors.textLight} value={altText} onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} - accessibilityLabel="Image alt text" + accessibilityLabel={_(msg`Image alt text`)} accessibilityHint="" accessibilityLabelledBy="imageAltText" autoFocus @@ -99,7 +102,7 @@ export function Component({image}: Props) { <TouchableOpacity testID="altTextImageSaveBtn" onPress={onPressSave} - accessibilityLabel="Save alt text" + accessibilityLabel={_(msg`Save alt text`)} accessibilityHint={`Saves alt text, which reads: ${altText}`} accessibilityRole="button"> <LinearGradient @@ -108,7 +111,7 @@ export function Component({image}: Props) { end={{x: 1, y: 1}} style={[styles.button]}> <Text type="button-lg" style={[s.white, s.bold]}> - Save + <Trans>Save</Trans> </Text> </LinearGradient> </TouchableOpacity> @@ -116,12 +119,12 @@ export function Component({image}: Props) { testID="altTextImageCancelBtn" onPress={onPressCancel} accessibilityRole="button" - accessibilityLabel="Cancel add image alt text" + accessibilityLabel={_(msg`Cancel add image alt text`)} accessibilityHint="" onAccessibilityEscape={onPressCancel}> <View style={[styles.button]}> <Text type="button-lg" style={[pal.textLight]}> - Cancel + <Trans>Cancel</Trans> </Text> </View> </TouchableOpacity> diff --git a/src/view/com/modals/BirthDateSettings.tsx b/src/view/com/modals/BirthDateSettings.tsx index 6927ba8d2..c78f06ed4 100644 --- a/src/view/com/modals/BirthDateSettings.tsx +++ b/src/view/com/modals/BirthDateSettings.tsx @@ -5,41 +5,47 @@ import { TouchableOpacity, View, } from 'react-native' -import {observer} from 'mobx-react-lite' import {Text} from '../util/text/Text' import {DateInput} from '../util/forms/DateInput' import {ErrorMessage} from '../util/error/ErrorMessage' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {cleanError} from 'lib/strings/errors' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import { + usePreferencesQuery, + usePreferencesSetBirthDateMutation, + UsePreferencesQueryResponse, +} from '#/state/queries/preferences' +import {logger} from '#/logger' export const snapPoints = ['50%'] -export const Component = observer(function Component({}: {}) { +function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) { const pal = usePalette('default') - const store = useStores() - const [date, setDate] = useState<Date>( - store.preferences.birthDate || new Date(), - ) - const [isProcessing, setIsProcessing] = useState<boolean>(false) - const [error, setError] = useState<string>('') const {isMobile} = useWebMediaQueries() + const {_} = useLingui() + const { + isPending, + isError, + error, + mutateAsync: setBirthDate, + } = usePreferencesSetBirthDateMutation() + const [date, setDate] = useState(preferences.birthDate || new Date()) + const {closeModal} = useModalControls() - const onSave = async () => { - setError('') - setIsProcessing(true) + const onSave = React.useCallback(async () => { try { - await store.preferences.setBirthDate(date) - store.shell.closeModal() + await setBirthDate({birthDate: date}) + closeModal() } catch (e) { - setError(cleanError(String(e))) - } finally { - setIsProcessing(false) + logger.error(`setBirthDate failed`, {error: e}) } - } + }, [date, setBirthDate, closeModal]) return ( <View @@ -47,12 +53,12 @@ export const Component = observer(function Component({}: {}) { style={[pal.view, styles.container, isMobile && {paddingHorizontal: 18}]}> <View style={styles.titleSection}> <Text type="title-lg" style={[pal.text, styles.title]}> - My Birthday + <Trans>My Birthday</Trans> </Text> </View> <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> - This information is not shared with other users. + <Trans>This information is not shared with other users.</Trans> </Text> <View> @@ -63,18 +69,18 @@ export const Component = observer(function Component({}: {}) { buttonType="default-light" buttonStyle={[pal.border, styles.dateInputButton]} buttonLabelType="lg" - accessibilityLabel="Birthday" + accessibilityLabel={_(msg`Birthday`)} accessibilityHint="Enter your birth date" accessibilityLabelledBy="birthDate" /> </View> - {error ? ( - <ErrorMessage message={error} style={styles.error} /> + {isError ? ( + <ErrorMessage message={cleanError(error)} style={styles.error} /> ) : undefined} <View style={[styles.btnContainer, pal.borderDark]}> - {isProcessing ? ( + {isPending ? ( <View style={styles.btn}> <ActivityIndicator color="#fff" /> </View> @@ -84,15 +90,27 @@ export const Component = observer(function Component({}: {}) { onPress={onSave} style={styles.btn} accessibilityRole="button" - accessibilityLabel="Save" + accessibilityLabel={_(msg`Save`)} accessibilityHint=""> - <Text style={[s.white, s.bold, s.f18]}>Save</Text> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Save</Trans> + </Text> </TouchableOpacity> )} </View> </View> ) -}) +} + +export function Component({}: {}) { + const {data: preferences} = usePreferencesQuery() + + return !preferences ? ( + <ActivityIndicator /> + ) : ( + <Inner preferences={preferences} /> + ) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx index 012570556..73ab33dd4 100644 --- a/src/view/com/modals/ChangeEmail.tsx +++ b/src/view/com/modals/ChangeEmail.tsx @@ -1,17 +1,19 @@ import React, {useState} from 'react' import {ActivityIndicator, SafeAreaView, StyleSheet, View} from 'react-native' import {ScrollView, TextInput} from './util' -import {observer} from 'mobx-react-lite' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import {ErrorMessage} from '../util/error/ErrorMessage' import * as Toast from '../util/Toast' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {cleanError} from 'lib/strings/errors' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useSession, useSessionApi, getAgent} from '#/state/session' enum Stages { InputEmail, @@ -21,32 +23,33 @@ enum Stages { export const snapPoints = ['90%'] -export const Component = observer(function Component({}: {}) { +export function Component() { const pal = usePalette('default') - const store = useStores() + const {currentAccount} = useSession() + const {updateCurrentAccount} = useSessionApi() + const {_} = useLingui() const [stage, setStage] = useState<Stages>(Stages.InputEmail) - const [email, setEmail] = useState<string>( - store.session.currentSession?.email || '', - ) + const [email, setEmail] = useState<string>(currentAccount?.email || '') const [confirmationCode, setConfirmationCode] = useState<string>('') const [isProcessing, setIsProcessing] = useState<boolean>(false) const [error, setError] = useState<string>('') const {isMobile} = useWebMediaQueries() + const {openModal, closeModal} = useModalControls() const onRequestChange = async () => { - if (email === store.session.currentSession?.email) { + if (email === currentAccount?.email) { setError('Enter your new email above') return } setError('') setIsProcessing(true) try { - const res = await store.agent.com.atproto.server.requestEmailUpdate() + const res = await getAgent().com.atproto.server.requestEmailUpdate() if (res.data.tokenRequired) { setStage(Stages.ConfirmCode) } else { - await store.agent.com.atproto.server.updateEmail({email: email.trim()}) - store.session.updateLocalAccountData({ + await getAgent().com.atproto.server.updateEmail({email: email.trim()}) + updateCurrentAccount({ email: email.trim(), emailConfirmed: false, }) @@ -60,7 +63,9 @@ export const Component = observer(function Component({}: {}) { // you can remove this any time after Oct2023 // -prf if (err === 'email must be confirmed (temporary)') { - err = `Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed.` + err = _( + msg`Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed.`, + ) } setError(err) } finally { @@ -72,11 +77,11 @@ export const Component = observer(function Component({}: {}) { setError('') setIsProcessing(true) try { - await store.agent.com.atproto.server.updateEmail({ + await getAgent().com.atproto.server.updateEmail({ email: email.trim(), token: confirmationCode.trim(), }) - store.session.updateLocalAccountData({ + updateCurrentAccount({ email: email.trim(), emailConfirmed: false, }) @@ -90,8 +95,8 @@ export const Component = observer(function Component({}: {}) { } const onVerify = async () => { - store.shell.closeModal() - store.shell.openModal({name: 'verify-email'}) + closeModal() + openModal({name: 'verify-email'}) } return ( @@ -101,26 +106,26 @@ export const Component = observer(function Component({}: {}) { style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> <View style={styles.titleSection}> <Text type="title-lg" style={[pal.text, styles.title]}> - {stage === Stages.InputEmail ? 'Change Your Email' : ''} - {stage === Stages.ConfirmCode ? 'Security Step Required' : ''} - {stage === Stages.Done ? 'Email Updated' : ''} + {stage === Stages.InputEmail ? _(msg`Change Your Email`) : ''} + {stage === Stages.ConfirmCode ? _(msg`Security Step Required`) : ''} + {stage === Stages.Done ? _(msg`Email Updated`) : ''} </Text> </View> <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> {stage === Stages.InputEmail ? ( - <>Enter your new email address below.</> + <Trans>Enter your new email address below.</Trans> ) : stage === Stages.ConfirmCode ? ( - <> + <Trans> An email has been sent to your previous address,{' '} - {store.session.currentSession?.email || ''}. It includes a - confirmation code which you can enter below. - </> + {currentAccount?.email || ''}. It includes a confirmation code + which you can enter below. + </Trans> ) : ( - <> + <Trans> Your email has been updated but not verified. As a next step, please verify your new email. - </> + </Trans> )} </Text> @@ -133,7 +138,7 @@ export const Component = observer(function Component({}: {}) { value={email} onChangeText={setEmail} accessible={true} - accessibilityLabel="Email" + accessibilityLabel={_(msg`Email`)} accessibilityHint="" autoCapitalize="none" autoComplete="email" @@ -149,7 +154,7 @@ export const Component = observer(function Component({}: {}) { value={confirmationCode} onChangeText={setConfirmationCode} accessible={true} - accessibilityLabel="Confirmation code" + accessibilityLabel={_(msg`Confirmation code`)} accessibilityHint="" autoCapitalize="none" autoComplete="off" @@ -173,9 +178,9 @@ export const Component = observer(function Component({}: {}) { testID="requestChangeBtn" type="primary" onPress={onRequestChange} - accessibilityLabel="Request Change" + accessibilityLabel={_(msg`Request Change`)} accessibilityHint="" - label="Request Change" + label={_(msg`Request Change`)} labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> @@ -185,9 +190,9 @@ export const Component = observer(function Component({}: {}) { testID="confirmBtn" type="primary" onPress={onConfirm} - accessibilityLabel="Confirm Change" + accessibilityLabel={_(msg`Confirm Change`)} accessibilityHint="" - label="Confirm Change" + label={_(msg`Confirm Change`)} labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> @@ -197,9 +202,9 @@ export const Component = observer(function Component({}: {}) { testID="verifyBtn" type="primary" onPress={onVerify} - accessibilityLabel="Verify New Email" + accessibilityLabel={_(msg`Verify New Email`)} accessibilityHint="" - label="Verify New Email" + label={_(msg`Verify New Email`)} labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> @@ -207,10 +212,12 @@ export const Component = observer(function Component({}: {}) { <Button testID="cancelBtn" type="default" - onPress={() => store.shell.closeModal()} - accessibilityLabel="Cancel" + onPress={() => { + closeModal() + }} + accessibilityLabel={_(msg`Cancel`)} accessibilityHint="" - label="Cancel" + label={_(msg`Cancel`)} labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> @@ -220,7 +227,7 @@ export const Component = observer(function Component({}: {}) { </ScrollView> </SafeAreaView> ) -}) +} const styles = StyleSheet.create({ titleSection: { diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index c54c1c043..03516d35a 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -1,5 +1,6 @@ import React, {useState} from 'react' import Clipboard from '@react-native-clipboard/clipboard' +import {ComAtprotoServerDescribeServer} from '@atproto/api' import * as Toast from '../util/Toast' import { ActivityIndicator, @@ -13,8 +14,6 @@ import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import {SelectableBtn} from '../util/forms/SelectableBtn' import {ErrorMessage} from '../util/error/ErrorMessage' -import {useStores} from 'state/index' -import {ServiceDescription} from 'state/models/session' import {s} from 'lib/styles' import {createFullHandle, makeValidHandle} from 'lib/strings/handles' import {usePalette} from 'lib/hooks/usePalette' @@ -22,75 +21,74 @@ import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics/analytics' import {cleanError} from 'lib/strings/errors' import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useServiceQuery} from '#/state/queries/service' +import {useUpdateHandleMutation, useFetchDid} from '#/state/queries/handle' +import { + useSession, + useSessionApi, + SessionAccount, + getAgent, +} from '#/state/session' export const snapPoints = ['100%'] -export function Component({onChanged}: {onChanged: () => void}) { - const store = useStores() - const [error, setError] = useState<string>('') +export type Props = {onChanged: () => void} + +export function Component(props: Props) { + const {currentAccount} = useSession() + const { + isLoading, + data: serviceInfo, + error: serviceInfoError, + } = useServiceQuery(getAgent().service.toString()) + + return isLoading || !currentAccount ? ( + <View style={{padding: 18}}> + <ActivityIndicator /> + </View> + ) : serviceInfoError || !serviceInfo ? ( + <ErrorMessage message={cleanError(serviceInfoError)} /> + ) : ( + <Inner + {...props} + currentAccount={currentAccount} + serviceInfo={serviceInfo} + /> + ) +} + +export function Inner({ + currentAccount, + serviceInfo, + onChanged, +}: Props & { + currentAccount: SessionAccount + serviceInfo: ComAtprotoServerDescribeServer.OutputSchema +}) { + const {_} = useLingui() const pal = usePalette('default') const {track} = useAnalytics() + const {updateCurrentAccount} = useSessionApi() + const {closeModal} = useModalControls() + const {mutateAsync: updateHandle, isPending: isUpdateHandlePending} = + useUpdateHandleMutation() + + const [error, setError] = useState<string>('') - const [isProcessing, setProcessing] = useState<boolean>(false) - const [retryDescribeTrigger, setRetryDescribeTrigger] = React.useState<any>( - {}, - ) - const [serviceDescription, setServiceDescription] = React.useState< - ServiceDescription | undefined - >(undefined) - const [userDomain, setUserDomain] = React.useState<string>('') const [isCustom, setCustom] = React.useState<boolean>(false) const [handle, setHandle] = React.useState<string>('') const [canSave, setCanSave] = React.useState<boolean>(false) - // init - // = - React.useEffect(() => { - let aborted = false - setError('') - setServiceDescription(undefined) - setProcessing(true) - - // load the service description so we can properly provision handles - store.session.describeService(String(store.agent.service)).then( - desc => { - if (aborted) { - return - } - setServiceDescription(desc) - setUserDomain(desc.availableUserDomains[0]) - setProcessing(false) - }, - err => { - if (aborted) { - return - } - setProcessing(false) - logger.warn( - `Failed to fetch service description for ${String( - store.agent.service, - )}`, - {error: err}, - ) - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - }, - ) - return () => { - aborted = true - } - }, [store.agent.service, store.session, retryDescribeTrigger]) + const userDomain = serviceInfo.availableUserDomains?.[0] // events // = const onPressCancel = React.useCallback(() => { - store.shell.closeModal() - }, [store]) - const onPressRetryConnect = React.useCallback( - () => setRetryDescribeTrigger({}), - [setRetryDescribeTrigger], - ) + closeModal() + }, [closeModal]) const onToggleCustom = React.useCallback(() => { // toggle between a provided domain vs a custom one setHandle('') @@ -101,32 +99,42 @@ export function Component({onChanged}: {onChanged: () => void}) { ) }, [setCustom, isCustom, track]) const onPressSave = React.useCallback(async () => { - setError('') - setProcessing(true) + if (!userDomain) { + logger.error(`ChangeHandle: userDomain is undefined`, { + service: serviceInfo, + }) + setError(`The service you've selected has no domains configured.`) + return + } + try { track('EditHandle:SetNewHandle') const newHandle = isCustom ? handle : createFullHandle(handle, userDomain) logger.debug(`Updating handle to ${newHandle}`) - await store.agent.updateHandle({ + await updateHandle({ + handle: newHandle, + }) + updateCurrentAccount({ handle: newHandle, }) - store.shell.closeModal() + closeModal() onChanged() } catch (err: any) { setError(cleanError(err)) logger.error('Failed to update handle', {handle, error: err}) } finally { - setProcessing(false) } }, [ setError, - setProcessing, handle, userDomain, - store, isCustom, onChanged, track, + closeModal, + updateCurrentAccount, + updateHandle, + serviceInfo, ]) // rendering @@ -138,7 +146,7 @@ export function Component({onChanged}: {onChanged: () => void}) { <TouchableOpacity onPress={onPressCancel} accessibilityRole="button" - accessibilityLabel="Cancel change handle" + accessibilityLabel={_(msg`Cancel change handle`)} accessibilityHint="Exits handle change process" onAccessibilityEscape={onPressCancel}> <Text type="lg" style={pal.textLight}> @@ -150,30 +158,19 @@ export function Component({onChanged}: {onChanged: () => void}) { type="2xl-bold" style={[styles.titleMiddle, pal.text]} numberOfLines={1}> - Change Handle + <Trans>Change Handle</Trans> </Text> <View style={styles.titleRight}> - {isProcessing ? ( + {isUpdateHandlePending ? ( <ActivityIndicator /> - ) : error && !serviceDescription ? ( - <TouchableOpacity - testID="retryConnectButton" - onPress={onPressRetryConnect} - accessibilityRole="button" - accessibilityLabel="Retry change handle" - accessibilityHint={`Retries handle change to ${handle}`}> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Retry - </Text> - </TouchableOpacity> ) : canSave ? ( <TouchableOpacity onPress={onPressSave} accessibilityRole="button" - accessibilityLabel="Save handle change" + accessibilityLabel={_(msg`Save handle change`)} accessibilityHint={`Saves handle change to ${handle}`}> <Text type="2xl-medium" style={pal.link}> - Save + <Trans>Save</Trans> </Text> </TouchableOpacity> ) : undefined} @@ -188,8 +185,9 @@ export function Component({onChanged}: {onChanged: () => void}) { {isCustom ? ( <CustomHandleForm + currentAccount={currentAccount} handle={handle} - isProcessing={isProcessing} + isProcessing={isUpdateHandlePending} canSave={canSave} onToggleCustom={onToggleCustom} setHandle={setHandle} @@ -200,7 +198,7 @@ export function Component({onChanged}: {onChanged: () => void}) { <ProvidedHandleForm handle={handle} userDomain={userDomain} - isProcessing={isProcessing} + isProcessing={isUpdateHandlePending} onToggleCustom={onToggleCustom} setHandle={setHandle} setCanSave={setCanSave} @@ -231,6 +229,7 @@ function ProvidedHandleForm({ }) { const pal = usePalette('default') const theme = useTheme() + const {_} = useLingui() // events // = @@ -263,12 +262,12 @@ function ProvidedHandleForm({ onChangeText={onChangeHandle} editable={!isProcessing} accessible={true} - accessibilityLabel="Handle" + accessibilityLabel={_(msg`Handle`)} accessibilityHint="Sets Bluesky username" /> </View> <Text type="md" style={[pal.textLight, s.pl10, s.pt10]}> - Your full handle will be{' '} + <Trans>Your full handle will be </Trans> <Text type="md-bold" style={pal.textLight}> @{createFullHandle(handle, userDomain)} </Text> @@ -277,9 +276,9 @@ function ProvidedHandleForm({ onPress={onToggleCustom} accessibilityRole="button" accessibilityHint="Hosting provider" - accessibilityLabel="Opens modal for using custom domain"> + accessibilityLabel={_(msg`Opens modal for using custom domain`)}> <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}> - I have my own domain + <Trans>I have my own domain</Trans> </Text> </TouchableOpacity> </> @@ -290,6 +289,7 @@ function ProvidedHandleForm({ * The form for using a custom domain */ function CustomHandleForm({ + currentAccount, handle, canSave, isProcessing, @@ -298,6 +298,7 @@ function CustomHandleForm({ onPressSave, setCanSave, }: { + currentAccount: SessionAccount handle: string canSave: boolean isProcessing: boolean @@ -306,20 +307,23 @@ function CustomHandleForm({ onPressSave: () => void setCanSave: (v: boolean) => void }) { - const store = useStores() const pal = usePalette('default') const palSecondary = usePalette('secondary') const palError = usePalette('error') const theme = useTheme() + const {_} = useLingui() const [isVerifying, setIsVerifying] = React.useState(false) const [error, setError] = React.useState<string>('') const [isDNSForm, setDNSForm] = React.useState<boolean>(true) + const fetchDid = useFetchDid() // events // = const onPressCopy = React.useCallback(() => { - Clipboard.setString(isDNSForm ? `did=${store.me.did}` : store.me.did) + Clipboard.setString( + isDNSForm ? `did=${currentAccount.did}` : currentAccount.did, + ) Toast.show('Copied to clipboard') - }, [store.me.did, isDNSForm]) + }, [currentAccount, isDNSForm]) const onChangeHandle = React.useCallback( (v: string) => { setHandle(v) @@ -334,13 +338,11 @@ function CustomHandleForm({ try { setIsVerifying(true) setError('') - const res = await store.agent.com.atproto.identity.resolveHandle({ - handle, - }) - if (res.data.did === store.me.did) { + const did = await fetchDid(handle) + if (did === currentAccount.did) { setCanSave(true) } else { - setError(`Incorrect DID returned (got ${res.data.did})`) + setError(`Incorrect DID returned (got ${did})`) } } catch (err: any) { setError(cleanError(err)) @@ -350,13 +352,13 @@ function CustomHandleForm({ } }, [ handle, - store.me.did, + currentAccount, setIsVerifying, setCanSave, setError, canSave, onPressSave, - store.agent, + fetchDid, ]) // rendering @@ -364,7 +366,7 @@ function CustomHandleForm({ return ( <> <Text type="md" style={[pal.text, s.pb5, s.pl5]} nativeID="customDomain"> - Enter the domain you want to use + <Trans>Enter the domain you want to use</Trans> </Text> <View style={[pal.btn, styles.textInputWrapper]}> <FontAwesomeIcon @@ -382,7 +384,7 @@ function CustomHandleForm({ onChangeText={onChangeHandle} editable={!isProcessing} accessibilityLabelledBy="customDomain" - accessibilityLabel="Custom domain" + accessibilityLabel={_(msg`Custom domain`)} accessibilityHint="Input your preferred hosting provider" /> </View> @@ -410,7 +412,7 @@ function CustomHandleForm({ {isDNSForm ? ( <> <Text type="md" style={[pal.text, s.pb5, s.pl5]}> - Add the following DNS record to your domain: + <Trans>Add the following DNS record to your domain:</Trans> </Text> <View style={[styles.dnsTable, pal.btn]}> <Text type="md-medium" style={[styles.dnsLabel, pal.text]}> @@ -434,7 +436,7 @@ function CustomHandleForm({ </Text> <View style={[styles.dnsValue]}> <Text type="mono" style={[styles.monoText, pal.text]}> - did={store.me.did} + did={currentAccount.did} </Text> </View> </View> @@ -448,7 +450,7 @@ function CustomHandleForm({ ) : ( <> <Text type="md" style={[pal.text, s.pb5, s.pl5]}> - Upload a text file to: + <Trans>Upload a text file to:</Trans> </Text> <View style={[styles.valueContainer, pal.btn]}> <View style={[styles.dnsValue]}> @@ -464,7 +466,7 @@ function CustomHandleForm({ <View style={[styles.valueContainer, pal.btn]}> <View style={[styles.dnsValue]}> <Text type="mono" style={[styles.monoText, pal.text]}> - {store.me.did} + {currentAccount.did} </Text> </View> </View> @@ -480,7 +482,7 @@ function CustomHandleForm({ {canSave === true && ( <View style={[styles.message, palSecondary.view]}> <Text type="md-medium" style={palSecondary.text}> - Domain verified! + <Trans>Domain verified!</Trans> </Text> </View> )} @@ -508,7 +510,7 @@ function CustomHandleForm({ <View style={styles.spacer} /> <TouchableOpacity onPress={onToggleCustom} - accessibilityLabel="Use default provider" + accessibilityLabel={_(msg`Use default provider`)} accessibilityHint="Use bsky.social as hosting provider"> <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}> Nevermind, create a handle for me diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx index c1324b1cb..5e869f396 100644 --- a/src/view/com/modals/Confirm.tsx +++ b/src/view/com/modals/Confirm.tsx @@ -6,13 +6,15 @@ import { View, } from 'react-native' import {Text} from '../util/text/Text' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {ErrorMessage} from '../util/error/ErrorMessage' import {cleanError} from 'lib/strings/errors' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' -import type {ConfirmModal} from 'state/models/ui/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import type {ConfirmModal} from '#/state/modals' +import {useModalControls} from '#/state/modals' export const snapPoints = ['50%'] @@ -26,7 +28,8 @@ export function Component({ cancelBtnText, }: ConfirmModal) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const {closeModal} = useModalControls() const [isProcessing, setIsProcessing] = useState<boolean>(false) const [error, setError] = useState<string>('') const onPress = async () => { @@ -34,7 +37,7 @@ export function Component({ setIsProcessing(true) try { await onPressConfirm() - store.shell.closeModal() + closeModal() return } catch (e: any) { setError(cleanError(e)) @@ -69,7 +72,7 @@ export function Component({ onPress={onPress} style={[styles.btn, confirmBtnStyle]} accessibilityRole="button" - accessibilityLabel="Confirm" + accessibilityLabel={_(msg`Confirm`)} accessibilityHint=""> <Text style={[s.white, s.bold, s.f18]}> {confirmBtnText ?? 'Confirm'} @@ -82,7 +85,7 @@ export function Component({ onPress={onPressCancel} style={[styles.btnCancel, s.mt10]} accessibilityRole="button" - accessibilityLabel="Cancel" + accessibilityLabel={_(msg`Cancel`)} accessibilityHint=""> <Text type="button-lg" style={pal.textLight}> {cancelBtnText ?? 'Cancel'} diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx index 9075d0272..8b42e1b1d 100644 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -1,214 +1,228 @@ import React from 'react' +import {LabelPreference} from '@atproto/api' import {StyleSheet, Pressable, View} from 'react-native' import LinearGradient from 'react-native-linear-gradient' -import {observer} from 'mobx-react-lite' import {ScrollView} from './util' -import {useStores} from 'state/index' -import {LabelPreference} from 'state/models/ui/preferences' import {s, colors, gradients} from 'lib/styles' import {Text} from '../util/text/Text' import {TextLink} from '../util/Link' import {ToggleButton} from '../util/forms/ToggleButton' import {Button} from '../util/forms/Button' import {usePalette} from 'lib/hooks/usePalette' -import {CONFIGURABLE_LABEL_GROUPS} from 'lib/labeling/const' import {isIOS} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import * as Toast from '../util/Toast' import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import { + usePreferencesQuery, + usePreferencesSetContentLabelMutation, + usePreferencesSetAdultContentMutation, + ConfigurableLabelGroup, + CONFIGURABLE_LABEL_GROUPS, + UsePreferencesQueryResponse, +} from '#/state/queries/preferences' export const snapPoints = ['90%'] -export const Component = observer( - function ContentFilteringSettingsImpl({}: {}) { - const store = useStores() - const {isMobile} = useWebMediaQueries() - const pal = usePalette('default') +export function Component({}: {}) { + const {isMobile} = useWebMediaQueries() + const pal = usePalette('default') + const {_} = useLingui() + const {closeModal} = useModalControls() + const {data: preferences} = usePreferencesQuery() - React.useEffect(() => { - store.preferences.sync() - }, [store]) + const onPressDone = React.useCallback(() => { + closeModal() + }, [closeModal]) - const onPressDone = React.useCallback(() => { - store.shell.closeModal() - }, [store]) + return ( + <View testID="contentFilteringModal" style={[pal.view, styles.container]}> + <Text style={[pal.text, styles.title]}> + <Trans>Content Filtering</Trans> + </Text> - return ( - <View testID="contentFilteringModal" style={[pal.view, styles.container]}> - <Text style={[pal.text, styles.title]}>Content Filtering</Text> - <ScrollView style={styles.scrollContainer}> - <AdultContentEnabledPref /> - <ContentLabelPref - group="nsfw" - disabled={!store.preferences.adultContentEnabled} - /> - <ContentLabelPref - group="nudity" - disabled={!store.preferences.adultContentEnabled} - /> - <ContentLabelPref - group="suggestive" - disabled={!store.preferences.adultContentEnabled} - /> - <ContentLabelPref - group="gore" - disabled={!store.preferences.adultContentEnabled} - /> - <ContentLabelPref group="hate" /> - <ContentLabelPref group="spam" /> - <ContentLabelPref group="impersonation" /> - <View style={{height: isMobile ? 60 : 0}} /> - </ScrollView> - <View - style={[ - styles.btnContainer, - isMobile && styles.btnContainerMobile, - pal.borderDark, - ]}> - <Pressable - testID="sendReportBtn" - onPress={onPressDone} - accessibilityRole="button" - accessibilityLabel="Done" - accessibilityHint=""> - <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]}>Done</Text> - </LinearGradient> - </Pressable> - </View> + <ScrollView style={styles.scrollContainer}> + <AdultContentEnabledPref /> + <ContentLabelPref + preferences={preferences} + labelGroup="nsfw" + disabled={!preferences?.adultContentEnabled} + /> + <ContentLabelPref + preferences={preferences} + labelGroup="nudity" + disabled={!preferences?.adultContentEnabled} + /> + <ContentLabelPref + preferences={preferences} + labelGroup="suggestive" + disabled={!preferences?.adultContentEnabled} + /> + <ContentLabelPref + preferences={preferences} + labelGroup="gore" + disabled={!preferences?.adultContentEnabled} + /> + <ContentLabelPref preferences={preferences} labelGroup="hate" /> + <ContentLabelPref preferences={preferences} labelGroup="spam" /> + <ContentLabelPref + preferences={preferences} + labelGroup="impersonation" + /> + <View style={{height: isMobile ? 60 : 0}} /> + </ScrollView> + + <View + style={[ + styles.btnContainer, + isMobile && styles.btnContainerMobile, + pal.borderDark, + ]}> + <Pressable + testID="sendReportBtn" + onPress={onPressDone} + accessibilityRole="button" + accessibilityLabel={_(msg`Done`)} + accessibilityHint=""> + <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>Done</Trans> + </Text> + </LinearGradient> + </Pressable> </View> - ) - }, -) + </View> + ) +} -const AdultContentEnabledPref = observer( - function AdultContentEnabledPrefImpl() { - const store = useStores() - const pal = usePalette('default') +function AdultContentEnabledPref() { + const pal = usePalette('default') + const {data: preferences} = usePreferencesQuery() + const {mutate, variables} = usePreferencesSetAdultContentMutation() + const {openModal} = useModalControls() - const onSetAge = () => store.shell.openModal({name: 'birth-date-settings'}) + const onSetAge = React.useCallback( + () => openModal({name: 'birth-date-settings'}), + [openModal], + ) - const onToggleAdultContent = async () => { - if (isIOS) { - return - } - try { - await store.preferences.setAdultContentEnabled( - !store.preferences.adultContentEnabled, - ) - } catch (e) { - Toast.show( - 'There was an issue syncing your preferences with the server', - ) - logger.error('Failed to update preferences with server', {error: e}) - } + const onToggleAdultContent = React.useCallback(async () => { + if (isIOS) return + + try { + mutate({ + enabled: !(variables?.enabled ?? preferences?.adultContentEnabled), + }) + } catch (e) { + Toast.show('There was an issue syncing your preferences with the server') + logger.error('Failed to update preferences with server', {error: e}) } + }, [variables, preferences, mutate]) - return ( - <View style={s.mb10}> - {isIOS ? ( - store.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" - /> - . - </Text> - ) - ) : typeof store.preferences.birthDate === 'undefined' ? ( - <View style={[pal.viewLight, styles.agePrompt]}> - <Text type="md" style={[pal.text, {flex: 1}]}> - Confirm your age to enable adult content. - </Text> - <Button type="primary" label="Set Age" onPress={onSetAge} /> - </View> - ) : (store.preferences.userAge || 0) >= 18 ? ( - <ToggleButton - type="default-light" - label="Enable Adult Content" - isSelected={store.preferences.adultContentEnabled} - onPress={onToggleAdultContent} - style={styles.toggleBtn} - /> - ) : ( - <View style={[pal.viewLight, styles.agePrompt]}> - <Text type="md" style={[pal.text, {flex: 1}]}> - You must be 18 or older to enable adult content. - </Text> - <Button type="primary" label="Set Age" onPress={onSetAge} /> - </View> - )} - </View> - ) - }, -) + 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" + /> + . + </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. + </Text> + <Button type="primary" label="Set Age" onPress={onSetAge} /> + </View> + ) : (preferences.userAge || 0) >= 18 ? ( + <ToggleButton + type="default-light" + label="Enable Adult Content" + isSelected={variables?.enabled ?? preferences?.adultContentEnabled} + onPress={onToggleAdultContent} + style={styles.toggleBtn} + /> + ) : ( + <View style={[pal.viewLight, styles.agePrompt]}> + <Text type="md" style={[pal.text, {flex: 1}]}> + You must be 18 or older to enable adult content. + </Text> + <Button type="primary" label="Set Age" onPress={onSetAge} /> + </View> + )} + </View> + ) +} // TODO: Refactor this component to pass labels down to each tab -const ContentLabelPref = observer(function ContentLabelPrefImpl({ - group, +function ContentLabelPref({ + preferences, + labelGroup, disabled, }: { - group: keyof typeof CONFIGURABLE_LABEL_GROUPS + preferences?: UsePreferencesQueryResponse + labelGroup: ConfigurableLabelGroup disabled?: boolean }) { - const store = useStores() const pal = usePalette('default') + const visibility = preferences?.contentLabels?.[labelGroup] + const {mutate, variables} = usePreferencesSetContentLabelMutation() const onChange = React.useCallback( - async (v: LabelPreference) => { - try { - await store.preferences.setContentLabelPref(group, v) - } catch (e) { - Toast.show( - 'There was an issue syncing your preferences with the server', - ) - logger.error('Failed to update preferences with server', {error: e}) - } + (vis: LabelPreference) => { + mutate({labelGroup, visibility: vis}) }, - [store, group], + [mutate, labelGroup], ) return ( <View style={[styles.contentLabelPref, pal.border]}> <View style={s.flex1}> <Text type="md-medium" style={[pal.text]}> - {CONFIGURABLE_LABEL_GROUPS[group].title} + {CONFIGURABLE_LABEL_GROUPS[labelGroup].title} </Text> - {typeof CONFIGURABLE_LABEL_GROUPS[group].subtitle === 'string' && ( + {typeof CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle === 'string' && ( <Text type="sm" style={[pal.textLight]}> - {CONFIGURABLE_LABEL_GROUPS[group].subtitle} + {CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle} </Text> )} </View> - {disabled ? ( + + {disabled || !visibility ? ( <Text type="sm-bold" style={pal.textLight}> Hide </Text> ) : ( <SelectGroup - current={store.preferences.contentLabels[group]} + current={variables?.visibility || visibility} onChange={onChange} - group={group} + labelGroup={labelGroup} /> )} </View> ) -}) +} interface SelectGroupProps { current: LabelPreference onChange: (v: LabelPreference) => void - group: keyof typeof CONFIGURABLE_LABEL_GROUPS + labelGroup: ConfigurableLabelGroup } -function SelectGroup({current, onChange, group}: SelectGroupProps) { +function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) { return ( <View style={styles.selectableBtns}> <SelectableBtn @@ -217,14 +231,14 @@ function SelectGroup({current, onChange, group}: SelectGroupProps) { label="Hide" left onChange={onChange} - group={group} + labelGroup={labelGroup} /> <SelectableBtn current={current} value="warn" label="Warn" onChange={onChange} - group={group} + labelGroup={labelGroup} /> <SelectableBtn current={current} @@ -232,7 +246,7 @@ function SelectGroup({current, onChange, group}: SelectGroupProps) { label="Show" right onChange={onChange} - group={group} + labelGroup={labelGroup} /> </View> ) @@ -245,7 +259,7 @@ interface SelectableBtnProps { left?: boolean right?: boolean onChange: (v: LabelPreference) => void - group: keyof typeof CONFIGURABLE_LABEL_GROUPS + labelGroup: ConfigurableLabelGroup } function SelectableBtn({ @@ -255,7 +269,7 @@ function SelectableBtn({ left, right, onChange, - group, + labelGroup, }: SelectableBtnProps) { const pal = usePalette('default') const palPrimary = usePalette('inverted') @@ -271,7 +285,7 @@ function SelectableBtn({ onPress={() => onChange(value)} accessibilityRole="button" accessibilityLabel={value} - accessibilityHint={`Set ${value} for ${group} content moderation policy`}> + accessibilityHint={`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 1ea12695f..8d13cdf2f 100644 --- a/src/view/com/modals/CreateOrEditList.tsx +++ b/src/view/com/modals/CreateOrEditList.tsx @@ -1,5 +1,4 @@ import React, {useState, useCallback, useMemo} from 'react' -import * as Toast from '../util/Toast' import { ActivityIndicator, KeyboardAvoidingView, @@ -9,12 +8,12 @@ import { TouchableOpacity, View, } from 'react-native' +import {AppBskyGraphDefs} 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' import {ErrorMessage} from '../util/error/ErrorMessage' -import {useStores} from 'state/index' -import {ListModel} from 'state/models/content/list' +import * as Toast from '../util/Toast' import {s, colors, gradients} from 'lib/styles' import {enforceLen} from 'lib/strings/helpers' import {compressIfNeeded} from 'lib/media/manip' @@ -24,6 +23,13 @@ import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics/analytics' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {cleanError, isNetworkError} from 'lib/strings/errors' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import { + useListCreateMutation, + useListMetadataMutation, +} from '#/state/queries/list' const MAX_NAME = 64 // todo const MAX_DESCRIPTION = 300 // todo @@ -37,18 +43,21 @@ export function Component({ }: { purpose?: string onSave?: (uri: string) => void - list?: ListModel + list?: AppBskyGraphDefs.ListView }) { - const store = useStores() + const {closeModal} = useModalControls() const {isMobile} = useWebMediaQueries() const [error, setError] = useState<string>('') const pal = usePalette('default') const theme = useTheme() const {track} = useAnalytics() + const {_} = useLingui() + const listCreateMutation = useListCreateMutation() + const listMetadataMutation = useListMetadataMutation() const activePurpose = useMemo(() => { - if (list?.data?.purpose) { - return list.data.purpose + if (list?.purpose) { + return list.purpose } if (purpose) { return purpose @@ -59,16 +68,16 @@ export function Component({ const purposeLabel = isCurateList ? 'User' : 'Moderation' const [isProcessing, setProcessing] = useState<boolean>(false) - const [name, setName] = useState<string>(list?.data?.name || '') + const [name, setName] = useState<string>(list?.name || '') const [description, setDescription] = useState<string>( - list?.data?.description || '', + list?.description || '', ) - const [avatar, setAvatar] = useState<string | undefined>(list?.data?.avatar) + const [avatar, setAvatar] = useState<string | undefined>(list?.avatar) const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>() const onPressCancel = useCallback(() => { - store.shell.closeModal() - }, [store]) + closeModal() + }, [closeModal]) const onSelectNewAvatar = useCallback( async (img: RNImage | null) => { @@ -106,7 +115,8 @@ export function Component({ } try { if (list) { - await list.updateMetadata({ + await listMetadataMutation.mutateAsync({ + uri: list.uri, name: nameTrimmed, description: description.trim(), avatar: newAvatar, @@ -114,7 +124,7 @@ export function Component({ Toast.show(`${purposeLabel} list updated`) onSave?.(list.uri) } else { - const res = await ListModel.createList(store, { + const res = await listCreateMutation.mutateAsync({ purpose: activePurpose, name, description, @@ -123,7 +133,7 @@ export function Component({ Toast.show(`${purposeLabel} list created`) onSave?.(res.uri) } - store.shell.closeModal() + closeModal() } catch (e: any) { if (isNetworkError(e)) { setError( @@ -140,7 +150,7 @@ export function Component({ setError, error, onSave, - store, + closeModal, activePurpose, isCurateList, purposeLabel, @@ -148,6 +158,8 @@ export function Component({ description, newAvatar, list, + listMetadataMutation, + listCreateMutation, ]) return ( @@ -161,14 +173,18 @@ export function Component({ ]} testID="createOrEditListModal"> <Text style={[styles.title, pal.text]}> - {list ? 'Edit' : 'New'} {purposeLabel} List + <Trans> + {list ? 'Edit' : 'New'} {purposeLabel} List + </Trans> </Text> {error !== '' && ( <View style={styles.errorContainer}> <ErrorMessage message={error} /> </View> )} - <Text style={[styles.label, pal.text]}>List Avatar</Text> + <Text style={[styles.label, pal.text]}> + <Trans>List Avatar</Trans> + </Text> <View style={[styles.avi, {borderColor: pal.colors.background}]}> <EditableUserAvatar type="list" @@ -180,7 +196,7 @@ export function Component({ <View style={styles.form}> <View> <Text style={[styles.label, pal.text]} nativeID="list-name"> - List Name + <Trans>List Name</Trans> </Text> <TextInput testID="editNameInput" @@ -192,14 +208,14 @@ export function Component({ value={name} onChangeText={v => setName(enforceLen(v, MAX_NAME))} accessible={true} - accessibilityLabel="Name" + accessibilityLabel={_(msg`Name`)} accessibilityHint="" accessibilityLabelledBy="list-name" /> </View> <View style={s.pb10}> <Text style={[styles.label, pal.text]} nativeID="list-description"> - Description + <Trans>Description</Trans> </Text> <TextInput testID="editDescriptionInput" @@ -215,7 +231,7 @@ export function Component({ value={description} onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} accessible={true} - accessibilityLabel="Description" + accessibilityLabel={_(msg`Description`)} accessibilityHint="" accessibilityLabelledBy="list-description" /> @@ -230,14 +246,16 @@ export function Component({ style={s.mt10} onPress={onPressSave} accessibilityRole="button" - accessibilityLabel="Save" + accessibilityLabel={_(msg`Save`)} accessibilityHint=""> <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]}>Save</Text> + <Text style={[s.white, s.bold]}> + <Trans>Save</Trans> + </Text> </LinearGradient> </TouchableOpacity> )} @@ -246,11 +264,13 @@ export function Component({ style={s.mt5} onPress={onPressCancel} accessibilityRole="button" - accessibilityLabel="Cancel" + accessibilityLabel={_(msg`Cancel`)} accessibilityHint="" onAccessibilityEscape={onPressCancel}> <View style={[styles.btn]}> - <Text style={[s.black, s.bold, pal.text]}>Cancel</Text> + <Text style={[s.black, s.bold, pal.text]}> + <Trans>Cancel</Trans> + </Text> </View> </TouchableOpacity> </View> diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index 50a4cd603..ee16d46b3 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -9,7 +9,6 @@ import {TextInput} from './util' import LinearGradient from 'react-native-linear-gradient' import * as Toast from '../util/Toast' import {Text} from '../util/text/Text' -import {useStores} from 'state/index' import {s, colors, gradients} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' @@ -17,13 +16,20 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {ErrorMessage} from '../util/error/ErrorMessage' import {cleanError} from 'lib/strings/errors' import {resetToTab} from '../../../Navigation' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useSession, useSessionApi, getAgent} from '#/state/session' export const snapPoints = ['60%'] export function Component({}: {}) { const pal = usePalette('default') const theme = useTheme() - const store = useStores() + const {currentAccount} = useSession() + const {clearCurrentAccount, removeAccount} = useSessionApi() + const {_} = useLingui() + const {closeModal} = useModalControls() const {isMobile} = useWebMediaQueries() const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false) const [confirmCode, setConfirmCode] = React.useState<string>('') @@ -34,7 +40,7 @@ export function Component({}: {}) { setError('') setIsProcessing(true) try { - await store.agent.com.atproto.server.requestAccountDelete() + await getAgent().com.atproto.server.requestAccountDelete() setIsEmailSent(true) } catch (e: any) { setError(cleanError(e)) @@ -42,34 +48,39 @@ export function Component({}: {}) { setIsProcessing(false) } const onPressConfirmDelete = async () => { + if (!currentAccount?.did) { + throw new Error(`DeleteAccount modal: currentAccount.did is undefined`) + } + setError('') setIsProcessing(true) const token = confirmCode.replace(/\s/g, '') try { - await store.agent.com.atproto.server.deleteAccount({ - did: store.me.did, + await getAgent().com.atproto.server.deleteAccount({ + did: currentAccount.did, password, token, }) Toast.show('Your account has been deleted') resetToTab('HomeTab') - store.session.clear() - store.shell.closeModal() + removeAccount(currentAccount) + clearCurrentAccount() + closeModal() } catch (e: any) { setError(cleanError(e)) } setIsProcessing(false) } const onCancel = () => { - store.shell.closeModal() + closeModal() } return ( <View style={[styles.container, pal.view]}> <View style={[styles.innerContainer, pal.view]}> <View style={[styles.titleContainer, pal.view]}> <Text type="title-xl" style={[s.textCenter, pal.text]}> - Delete Account + <Trans>Delete Account</Trans> </Text> <View style={[pal.view, s.flexRow]}> <Text type="title-xl" style={[pal.text, s.bold]}> @@ -83,7 +94,7 @@ export function Component({}: {}) { pal.text, s.bold, ]}> - {store.me.handle} + {currentAccount?.handle} </Text> <Text type="title-xl" style={[pal.text, s.bold]}> {'"'} @@ -93,8 +104,10 @@ export function Component({}: {}) { {!isEmailSent ? ( <> <Text type="lg" style={[styles.description, pal.text]}> - For security reasons, we'll need to send a confirmation code to - your email address. + <Trans> + For security reasons, we'll need to send a confirmation code to + your email address. + </Trans> </Text> {error ? ( <View style={s.mt10}> @@ -111,7 +124,7 @@ export function Component({}: {}) { style={styles.mt20} onPress={onPressSendEmail} accessibilityRole="button" - accessibilityLabel="Send email" + accessibilityLabel={_(msg`Send email`)} accessibilityHint="Sends email with confirmation code for account deletion"> <LinearGradient colors={[ @@ -122,7 +135,7 @@ export function Component({}: {}) { end={{x: 1, y: 1}} style={[styles.btn]}> <Text type="button-lg" style={[s.white, s.bold]}> - Send Email + <Trans>Send Email</Trans> </Text> </LinearGradient> </TouchableOpacity> @@ -130,11 +143,11 @@ export function Component({}: {}) { style={[styles.btn, s.mt10]} onPress={onCancel} accessibilityRole="button" - accessibilityLabel="Cancel account deletion" + accessibilityLabel={_(msg`Cancel account deletion`)} accessibilityHint="" onAccessibilityEscape={onCancel}> <Text type="button-lg" style={pal.textLight}> - Cancel + <Trans>Cancel</Trans> </Text> </TouchableOpacity> </> @@ -147,8 +160,10 @@ export function Component({}: {}) { type="lg" style={styles.description} nativeID="confirmationCode"> - Check your inbox for an email with the confirmation code to enter - below: + <Trans> + Check your inbox for an email with the confirmation code to + enter below: + </Trans> </Text> <TextInput style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]} @@ -158,11 +173,11 @@ export function Component({}: {}) { value={confirmCode} onChangeText={setConfirmCode} accessibilityLabelledBy="confirmationCode" - accessibilityLabel="Confirmation code" + accessibilityLabel={_(msg`Confirmation code`)} accessibilityHint="Input confirmation code for account deletion" /> <Text type="lg" style={styles.description} nativeID="password"> - Please enter your password as well: + <Trans>Please enter your password as well:</Trans> </Text> <TextInput style={[styles.textInput, pal.borderDark, pal.text]} @@ -173,7 +188,7 @@ export function Component({}: {}) { value={password} onChangeText={setPassword} accessibilityLabelledBy="password" - accessibilityLabel="Password" + accessibilityLabel={_(msg`Password`)} accessibilityHint="Input password for account deletion" /> {error ? ( @@ -191,21 +206,21 @@ export function Component({}: {}) { style={[styles.btn, styles.evilBtn, styles.mt20]} onPress={onPressConfirmDelete} accessibilityRole="button" - accessibilityLabel="Confirm delete account" + accessibilityLabel={_(msg`Confirm delete account`)} accessibilityHint=""> <Text type="button-lg" style={[s.white, s.bold]}> - Delete my account + <Trans>Delete my account</Trans> </Text> </TouchableOpacity> <TouchableOpacity style={[styles.btn, s.mt10]} onPress={onCancel} accessibilityRole="button" - accessibilityLabel="Cancel account deletion" + accessibilityLabel={_(msg`Cancel account deletion`)} accessibilityHint="Exits account deletion process" onAccessibilityEscape={onCancel}> <Text type="button-lg" style={pal.textLight}> - Cancel + <Trans>Cancel</Trans> </Text> </TouchableOpacity> </> diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx index dcb6668c7..753907472 100644 --- a/src/view/com/modals/EditImage.tsx +++ b/src/view/com/modals/EditImage.tsx @@ -6,7 +6,6 @@ import {gradients, s} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' import {Text} from '../util/text/Text' import LinearGradient from 'react-native-linear-gradient' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import ImageEditor, {Position} from 'react-avatar-editor' import {TextInput} from './util' @@ -19,6 +18,9 @@ import {Slider} from '@miblanchard/react-native-slider' import {MaterialIcons} from '@expo/vector-icons' import {observer} from 'mobx-react-lite' import {getKeys} from 'lib/type-assertions' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export const snapPoints = ['80%'] @@ -52,9 +54,10 @@ export const Component = observer(function EditImageImpl({ }: Props) { const pal = usePalette('default') const theme = useTheme() - const store = useStores() + const {_} = useLingui() const windowDimensions = useWindowDimensions() const {isMobile} = useWebMediaQueries() + const {closeModal} = useModalControls() const { aspectRatio, @@ -128,8 +131,8 @@ export const Component = observer(function EditImageImpl({ }, [image]) const onCloseModal = useCallback(() => { - store.shell.closeModal() - }, [store.shell]) + closeModal() + }, [closeModal]) const onPressCancel = useCallback(async () => { await gallery.previous(image) @@ -200,7 +203,9 @@ export const Component = observer(function EditImageImpl({ paddingHorizontal: isMobile ? 16 : undefined, }, ]}> - <Text style={[styles.title, pal.text]}>Edit image</Text> + <Text style={[styles.title, pal.text]}> + <Trans>Edit image</Trans> + </Text> <View style={[styles.gap18, s.flexRow]}> <View> <View @@ -228,7 +233,7 @@ export const Component = observer(function EditImageImpl({ <View> {!isMobile ? ( <Text type="sm-bold" style={pal.text}> - Ratios + <Trans>Ratios</Trans> </Text> ) : null} <View style={imgControlStyles}> @@ -263,7 +268,7 @@ export const Component = observer(function EditImageImpl({ </View> {!isMobile ? ( <Text type="sm-bold" style={[pal.text, styles.subsection]}> - Transformations + <Trans>Transformations</Trans> </Text> ) : null} <View style={imgControlStyles}> @@ -291,7 +296,7 @@ export const Component = observer(function EditImageImpl({ </View> <View style={[styles.gap18, styles.bottomSection, pal.border]}> <Text type="sm-bold" style={pal.text} nativeID="alt-text"> - Accessibility + <Trans>Accessibility</Trans> </Text> <TextInput testID="altTextImageInput" @@ -307,7 +312,7 @@ export const Component = observer(function EditImageImpl({ multiline value={altText} onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} - accessibilityLabel="Alt text" + accessibilityLabel={_(msg`Alt text`)} accessibilityHint="" accessibilityLabelledBy="alt-text" /> @@ -315,7 +320,7 @@ export const Component = observer(function EditImageImpl({ <View style={styles.btns}> <Pressable onPress={onPressCancel} accessibilityRole="button"> <Text type="xl" style={pal.link}> - Cancel + <Trans>Cancel</Trans> </Text> </Pressable> <Pressable onPress={onPressSave} accessibilityRole="button"> @@ -325,7 +330,7 @@ export const Component = observer(function EditImageImpl({ end={{x: 1, y: 1}} style={[styles.btn]}> <Text type="xl-medium" style={s.white}> - Done + <Trans>Done</Trans> </Text> </LinearGradient> </Pressable> diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index dfd5305f5..e044f8c0e 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -11,10 +11,9 @@ import { } from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {Image as RNImage} from 'react-native-image-crop-picker' +import {AppBskyActorDefs} from '@atproto/api' import {Text} from '../util/text/Text' import {ErrorMessage} from '../util/error/ErrorMessage' -import {useStores} from 'state/index' -import {ProfileModel} from 'state/models/content/profile' import {s, colors, gradients} from 'lib/styles' import {enforceLen} from 'lib/strings/helpers' import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants' @@ -24,9 +23,14 @@ import {EditableUserAvatar} from '../util/UserAvatar' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics/analytics' -import {cleanError, isNetworkError} from 'lib/strings/errors' +import {cleanError} from 'lib/strings/errors' import Animated, {FadeOut} from 'react-native-reanimated' import {isWeb} from 'platform/detection' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useProfileUpdateMutation} from '#/state/queries/profile' +import {logger} from '#/logger' const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity) @@ -34,30 +38,30 @@ const AnimatedTouchableOpacity = export const snapPoints = ['fullscreen'] export function Component({ - profileView, + profile, onUpdate, }: { - profileView: ProfileModel + profile: AppBskyActorDefs.ProfileViewDetailed onUpdate?: () => void }) { - const store = useStores() - const [error, setError] = useState<string>('') const pal = usePalette('default') const theme = useTheme() const {track} = useAnalytics() - - const [isProcessing, setProcessing] = useState<boolean>(false) + const {_} = useLingui() + const {closeModal} = useModalControls() + const updateMutation = useProfileUpdateMutation() + const [imageError, setImageError] = useState<string>('') const [displayName, setDisplayName] = useState<string>( - profileView.displayName || '', + profile.displayName || '', ) const [description, setDescription] = useState<string>( - profileView.description || '', + profile.description || '', ) const [userBanner, setUserBanner] = useState<string | undefined | null>( - profileView.banner, + profile.banner, ) const [userAvatar, setUserAvatar] = useState<string | undefined | null>( - profileView.avatar, + profile.avatar, ) const [newUserBanner, setNewUserBanner] = useState< RNImage | undefined | null @@ -66,10 +70,11 @@ export function Component({ RNImage | undefined | null >() const onPressCancel = () => { - store.shell.closeModal() + closeModal() } const onSelectNewAvatar = useCallback( async (img: RNImage | null) => { + setImageError('') if (img === null) { setNewUserAvatar(null) setUserAvatar(null) @@ -81,14 +86,15 @@ export function Component({ setNewUserAvatar(finalImg) setUserAvatar(finalImg.path) } catch (e: any) { - setError(cleanError(e)) + setImageError(cleanError(e)) } }, - [track, setNewUserAvatar, setUserAvatar, setError], + [track, setNewUserAvatar, setUserAvatar, setImageError], ) const onSelectNewBanner = useCallback( async (img: RNImage | null) => { + setImageError('') if (!img) { setNewUserBanner(null) setUserBanner(null) @@ -100,58 +106,50 @@ export function Component({ setNewUserBanner(finalImg) setUserBanner(finalImg.path) } catch (e: any) { - setError(cleanError(e)) + setImageError(cleanError(e)) } }, - [track, setNewUserBanner, setUserBanner, setError], + [track, setNewUserBanner, setUserBanner, setImageError], ) const onPressSave = useCallback(async () => { track('EditProfile:Save') - setProcessing(true) - if (error) { - setError('') - } + setImageError('') try { - await profileView.updateProfile( - { + await updateMutation.mutateAsync({ + profile, + updates: { displayName, description, }, newUserAvatar, newUserBanner, - ) + }) Toast.show('Profile updated') onUpdate?.() - store.shell.closeModal() + closeModal() } catch (e: any) { - if (isNetworkError(e)) { - setError( - 'Failed to save your profile. Check your internet connection and try again.', - ) - } else { - setError(cleanError(e)) - } + logger.error('Failed to update user profile', {error: String(e)}) } - setProcessing(false) }, [ track, - setProcessing, - setError, - error, - profileView, + updateMutation, + profile, onUpdate, - store, + closeModal, displayName, description, newUserAvatar, newUserBanner, + setImageError, ]) return ( <KeyboardAvoidingView style={s.flex1} behavior="height"> <ScrollView style={[pal.view]} testID="editProfileModal"> - <Text style={[styles.title, pal.text]}>Edit my profile</Text> + <Text style={[styles.title, pal.text]}> + <Trans>Edit my profile</Trans> + </Text> <View style={styles.photos}> <UserBanner banner={userBanner} @@ -165,14 +163,21 @@ export function Component({ /> </View> </View> - {error !== '' && ( + {updateMutation.isError && ( + <View style={styles.errorContainer}> + <ErrorMessage message={cleanError(updateMutation.error)} /> + </View> + )} + {imageError !== '' && ( <View style={styles.errorContainer}> - <ErrorMessage message={error} /> + <ErrorMessage message={imageError} /> </View> )} <View style={styles.form}> <View> - <Text style={[styles.label, pal.text]}>Display Name</Text> + <Text style={[styles.label, pal.text]}> + <Trans>Display Name</Trans> + </Text> <TextInput testID="editProfileDisplayNameInput" style={[styles.textInput, pal.border, pal.text]} @@ -183,12 +188,14 @@ export function Component({ setDisplayName(enforceLen(v, MAX_DISPLAY_NAME)) } accessible={true} - accessibilityLabel="Display name" + accessibilityLabel={_(msg`Display name`)} accessibilityHint="Edit your display name" /> </View> <View style={s.pb10}> - <Text style={[styles.label, pal.text]}>Description</Text> + <Text style={[styles.label, pal.text]}> + <Trans>Description</Trans> + </Text> <TextInput testID="editProfileDescriptionInput" style={[styles.textArea, pal.border, pal.text]} @@ -199,11 +206,11 @@ export function Component({ value={description} onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} accessible={true} - accessibilityLabel="Description" + accessibilityLabel={_(msg`Description`)} accessibilityHint="Edit your profile description" /> </View> - {isProcessing ? ( + {updateMutation.isPending ? ( <View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}> <ActivityIndicator /> </View> @@ -213,29 +220,33 @@ export function Component({ style={s.mt10} onPress={onPressSave} accessibilityRole="button" - accessibilityLabel="Save" + accessibilityLabel={_(msg`Save`)} accessibilityHint="Saves any changes to your profile"> <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]}>Save Changes</Text> + <Text style={[s.white, s.bold]}> + <Trans>Save Changes</Trans> + </Text> </LinearGradient> </TouchableOpacity> )} - {!isProcessing && ( + {!updateMutation.isPending && ( <AnimatedTouchableOpacity exiting={!isWeb ? FadeOut : undefined} testID="editProfileCancelBtn" style={s.mt5} onPress={onPressCancel} accessibilityRole="button" - accessibilityLabel="Cancel profile editing" + accessibilityLabel={_(msg`Cancel profile editing`)} accessibilityHint="" onAccessibilityEscape={onPressCancel}> <View style={[styles.btn]}> - <Text style={[s.black, s.bold, pal.text]}>Cancel</Text> + <Text style={[s.black, s.bold, pal.text]}> + <Trans>Cancel</Trans> + </Text> </View> </AnimatedTouchableOpacity> )} diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx index 09cfd4de7..82a826aca 100644 --- a/src/view/com/modals/InviteCodes.tsx +++ b/src/view/com/modals/InviteCodes.tsx @@ -1,6 +1,11 @@ import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' +import { + StyleSheet, + TouchableOpacity, + View, + ActivityIndicator, +} from 'react-native' +import {ComAtprotoServerDefs} from '@atproto/api' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -9,30 +14,57 @@ import Clipboard from '@react-native-clipboard/clipboard' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import * as Toast from '../util/Toast' -import {useStores} from 'state/index' 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 {cleanError} from 'lib/strings/errors' +import {useModalControls} from '#/state/modals' +import {useInvitesState, useInvitesAPI} from '#/state/invites' +import {UserInfoText} from '../util/UserInfoText' +import {makeProfileLink} from '#/lib/routes/links' +import {Link} from '../util/Link' +import {ErrorMessage} from '../util/error/ErrorMessage' +import { + useInviteCodesQuery, + InviteCodesQueryResponse, +} from '#/state/queries/invites' export const snapPoints = ['70%'] -export function Component({}: {}) { +export function Component() { + const {isLoading, data: invites, error} = useInviteCodesQuery() + + return error ? ( + <ErrorMessage message={cleanError(error)} /> + ) : isLoading || !invites ? ( + <View style={{padding: 18}}> + <ActivityIndicator /> + </View> + ) : ( + <Inner invites={invites} /> + ) +} + +export function Inner({invites}: {invites: InviteCodesQueryResponse}) { const pal = usePalette('default') - const store = useStores() + const {closeModal} = useModalControls() const {isTabletOrDesktop} = useWebMediaQueries() const onClose = React.useCallback(() => { - store.shell.closeModal() - }, [store]) + closeModal() + }, [closeModal]) - if (store.me.invites.length === 0) { + if (invites.all.length === 0) { return ( <View style={[styles.container, pal.view]} testID="inviteCodesModal"> <View style={[styles.empty, pal.viewLight]}> <Text type="lg" style={[pal.text, styles.emptyText]}> - You don't have any invite codes yet! We'll send you some when you've - been on Bluesky for a little longer. + <Trans> + You don't have any invite codes yet! We'll send you some when + you've been on Bluesky for a little longer. + </Trans> </Text> </View> <View style={styles.flex1} /> @@ -56,18 +88,29 @@ export function Component({}: {}) { return ( <View style={[styles.container, pal.view]} testID="inviteCodesModal"> <Text type="title-xl" style={[styles.title, pal.text]}> - Invite a Friend + <Trans>Invite a Friend</Trans> </Text> <Text type="lg" style={[styles.description, pal.text]}> - Each code works once. You'll receive more invite codes periodically. + <Trans> + Each code works once. You'll receive more invite codes periodically. + </Trans> </Text> <ScrollView style={[styles.scrollContainer, pal.border]}> - {store.me.invites.map((invite, i) => ( + {invites.available.map((invite, i) => ( <InviteCode testID={`inviteCode-${i}`} key={invite.code} - code={invite.code} - used={invite.available - invite.uses.length <= 0 || invite.disabled} + invite={invite} + invites={invites} + /> + ))} + {invites.used.map((invite, i) => ( + <InviteCode + used + testID={`inviteCode-${i}`} + key={invite.code} + invite={invite} + invites={invites} /> ))} </ScrollView> @@ -85,56 +128,89 @@ export function Component({}: {}) { ) } -const InviteCode = observer(function InviteCodeImpl({ +function InviteCode({ testID, - code, + invite, used, + invites, }: { testID: string - code: string + invite: ComAtprotoServerDefs.InviteCode used?: boolean + invites: InviteCodesQueryResponse }) { const pal = usePalette('default') - const store = useStores() - const {invitesAvailable} = store.me + const invitesState = useInvitesState() + const {setInviteCopied} = useInvitesAPI() const onPress = React.useCallback(() => { - Clipboard.setString(code) + Clipboard.setString(invite.code) Toast.show('Copied to clipboard') - store.invitedUsers.setInviteCopied(code) - }, [store, code]) + setInviteCopied(invite.code) + }, [setInviteCopied, invite]) return ( - <TouchableOpacity - testID={testID} - style={[styles.inviteCode, pal.border]} - onPress={onPress} - accessibilityRole="button" - accessibilityLabel={ - invitesAvailable === 1 - ? 'Invite codes: 1 available' - : `Invite codes: ${invitesAvailable} available` - } - accessibilityHint="Opens list of invite codes"> - <Text - testID={`${testID}-code`} - type={used ? 'md' : 'md-bold'} - style={used ? [pal.textLight, styles.strikeThrough] : pal.text}> - {code} - </Text> - <View style={styles.flex1} /> - {!used && store.invitedUsers.isInviteCopied(code) && ( - <Text style={[pal.textLight, styles.codeCopied]}>Copied</Text> - )} - {!used && ( - <FontAwesomeIcon - icon={['far', 'clone']} - style={pal.text as FontAwesomeIconStyle} - /> - )} - </TouchableOpacity> + <View + style={[ + pal.border, + {borderBottomWidth: 1, paddingHorizontal: 20, paddingVertical: 14}, + ]}> + <TouchableOpacity + testID={testID} + style={[styles.inviteCode]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={ + invites.available.length === 1 + ? 'Invite codes: 1 available' + : `Invite codes: ${invites.available.length} available` + } + accessibilityHint="Opens list of invite codes"> + <Text + testID={`${testID}-code`} + type={used ? 'md' : 'md-bold'} + style={used ? [pal.textLight, styles.strikeThrough] : pal.text}> + {invite.code} + </Text> + <View style={styles.flex1} /> + {!used && invitesState.copiedInvites.includes(invite.code) && ( + <Text style={[pal.textLight, styles.codeCopied]}> + <Trans>Copied</Trans> + </Text> + )} + {!used && ( + <FontAwesomeIcon + icon={['far', 'clone']} + style={pal.text as FontAwesomeIconStyle} + /> + )} + </TouchableOpacity> + {invite.uses.length > 0 ? ( + <View + style={{ + flexDirection: 'column', + gap: 8, + paddingTop: 6, + }}> + <Text style={pal.text}> + <Trans>Used by:</Trans> + </Text> + {invite.uses.map(use => ( + <Link + key={use.usedBy} + href={makeProfileLink({handle: use.usedBy, did: ''})} + style={{ + flexDirection: 'row', + }}> + <Text style={pal.text}>• </Text> + <UserInfoText did={use.usedBy} style={pal.link} /> + </Link> + ))} + </View> + ) : null} + </View> ) -}) +} const styles = StyleSheet.create({ container: { @@ -176,9 +252,6 @@ const styles = StyleSheet.create({ inviteCode: { flexDirection: 'row', alignItems: 'center', - borderBottomWidth: 1, - paddingHorizontal: 20, - paddingVertical: 14, }, codeCopied: { marginRight: 8, diff --git a/src/view/com/modals/LinkWarning.tsx b/src/view/com/modals/LinkWarning.tsx index 67a156af4..39e6cc3e6 100644 --- a/src/view/com/modals/LinkWarning.tsx +++ b/src/view/com/modals/LinkWarning.tsx @@ -1,33 +1,29 @@ import React from 'react' import {Linking, SafeAreaView, StyleSheet, View} from 'react-native' import {ScrollView} from './util' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {isPossiblyAUrl, splitApexDomain} from 'lib/strings/url-helpers' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export const snapPoints = ['50%'] -export const Component = observer(function Component({ - text, - href, -}: { - text: string - href: string -}) { +export function Component({text, href}: {text: string; href: string}) { const pal = usePalette('default') - const store = useStores() + const {closeModal} = useModalControls() const {isMobile} = useWebMediaQueries() + const {_} = useLingui() const potentiallyMisleading = isPossiblyAUrl(text) const onPressVisit = () => { - store.shell.closeModal() + closeModal() Linking.openURL(href) } @@ -45,26 +41,26 @@ export const Component = observer(function Component({ size={18} /> <Text type="title-lg" style={[pal.text, styles.title]}> - Potentially Misleading Link + <Trans>Potentially Misleading Link</Trans> </Text> </> ) : ( <Text type="title-lg" style={[pal.text, styles.title]}> - Leaving Bluesky + <Trans>Leaving Bluesky</Trans> </Text> )} </View> <View style={{gap: 10}}> <Text type="lg" style={pal.text}> - This link is taking you to the following website: + <Trans>This link is taking you to the following website:</Trans> </Text> <LinkBox href={href} /> {potentiallyMisleading && ( <Text type="lg" style={pal.text}> - Make sure this is where you intend to go! + <Trans>Make sure this is where you intend to go!</Trans> </Text> )} </View> @@ -74,7 +70,7 @@ export const Component = observer(function Component({ testID="confirmBtn" type="primary" onPress={onPressVisit} - accessibilityLabel="Visit Site" + accessibilityLabel={_(msg`Visit Site`)} accessibilityHint="" label="Visit Site" labelContainerStyle={{justifyContent: 'center', padding: 4}} @@ -83,8 +79,10 @@ export const Component = observer(function Component({ <Button testID="cancelBtn" type="default" - onPress={() => store.shell.closeModal()} - accessibilityLabel="Cancel" + onPress={() => { + closeModal() + }} + accessibilityLabel={_(msg`Cancel`)} accessibilityHint="" label="Cancel" labelContainerStyle={{justifyContent: 'center', padding: 4}} @@ -94,7 +92,7 @@ export const Component = observer(function Component({ </ScrollView> </SafeAreaView> ) -}) +} function LinkBox({href}: {href: string}) { const pal = usePalette('default') diff --git a/src/view/com/modals/ListAddUser.tsx b/src/view/com/modals/ListAddRemoveUsers.tsx index a04e2d186..14e16d6bf 100644 --- a/src/view/com/modals/ListAddUser.tsx +++ b/src/view/com/modals/ListAddRemoveUsers.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useCallback, useState, useMemo} from 'react' +import React, {useCallback, useState} from 'react' import { ActivityIndicator, Pressable, @@ -6,17 +6,13 @@ import { StyleSheet, View, } from 'react-native' -import {AppBskyActorDefs} from '@atproto/api' +import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' import {ScrollView, TextInput} from './util' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import {UserAvatar} from '../util/UserAvatar' import * as Toast from '../util/Toast' -import {useStores} from 'state/index' -import {ListModel} from 'state/models/content/list' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' @@ -26,47 +22,40 @@ import {cleanError} from 'lib/strings/errors' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {HITSLOP_20} from '#/lib/constants' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import { + useDangerousListMembershipsQuery, + getMembership, + ListMembersip, + useListMembershipAddMutation, + useListMembershipRemoveMutation, +} from '#/state/queries/list-memberships' +import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' export const snapPoints = ['90%'] -export const Component = observer(function Component({ +export function Component({ list, - onAdd, + onChange, }: { - list: ListModel - onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void + list: AppBskyGraphDefs.ListView + onChange?: ( + type: 'add' | 'remove', + profile: AppBskyActorDefs.ProfileViewBasic, + ) => void }) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const {closeModal} = useModalControls() const {isMobile} = useWebMediaQueries() const [query, setQuery] = useState('') - const autocompleteView = useMemo<UserAutocompleteModel>( - () => new UserAutocompleteModel(store), - [store], - ) + const autocomplete = useActorAutocompleteQuery(query) + const {data: memberships} = useDangerousListMembershipsQuery() const [isKeyboardVisible] = useIsKeyboardVisible() - // initial setup - useEffect(() => { - autocompleteView.setup().then(() => { - autocompleteView.setPrefix('') - }) - autocompleteView.setActive(true) - list.loadAll() - }, [autocompleteView, list]) - - const onChangeQuery = useCallback( - (text: string) => { - setQuery(text) - autocompleteView.setPrefix(text) - }, - [setQuery, autocompleteView], - ) - - const onPressCancelSearch = useCallback( - () => onChangeQuery(''), - [onChangeQuery], - ) + const onPressCancelSearch = useCallback(() => setQuery(''), [setQuery]) return ( <SafeAreaView @@ -81,9 +70,9 @@ export const Component = observer(function Component({ placeholder="Search for users" placeholderTextColor={pal.colors.textLight} value={query} - onChangeText={onChangeQuery} + onChangeText={setQuery} accessible={true} - accessibilityLabel="Search" + accessibilityLabel={_(msg`Search`)} accessibilityHint="" autoFocus autoCapitalize="none" @@ -95,7 +84,7 @@ export const Component = observer(function Component({ <Pressable onPress={onPressCancelSearch} accessibilityRole="button" - accessibilityLabel="Cancel search" + accessibilityLabel={_(msg`Cancel search`)} accessibilityHint="Exits inputting search query" onAccessibilityEscape={onPressCancelSearch} hitSlop={HITSLOP_20}> @@ -111,19 +100,20 @@ export const Component = observer(function Component({ style={[s.flex1]} keyboardDismissMode="none" keyboardShouldPersistTaps="always"> - {autocompleteView.isLoading ? ( + {autocomplete.isLoading ? ( <View style={{marginVertical: 20}}> <ActivityIndicator /> </View> - ) : autocompleteView.suggestions.length ? ( + ) : autocomplete.data?.length ? ( <> - {autocompleteView.suggestions.slice(0, 40).map((item, i) => ( + {autocomplete.data.slice(0, 40).map((item, i) => ( <UserResult key={item.did} list={list} profile={item} + memberships={memberships} noBorder={i === 0} - onAdd={onAdd} + onChange={onChange} /> ))} </> @@ -134,7 +124,7 @@ export const Component = observer(function Component({ pal.textLight, {paddingHorizontal: 12, paddingVertical: 16}, ]}> - No results found for {autocompleteView.prefix} + <Trans>No results found for {query}</Trans> </Text> )} </ScrollView> @@ -146,8 +136,10 @@ export const Component = observer(function Component({ <Button testID="doneBtn" type="default" - onPress={() => store.shell.closeModal()} - accessibilityLabel="Done" + onPress={() => { + closeModal() + }} + accessibilityLabel={_(msg`Done`)} accessibilityHint="" label="Done" labelContainerStyle={{justifyContent: 'center', padding: 4}} @@ -157,36 +149,71 @@ export const Component = observer(function Component({ </View> </SafeAreaView> ) -}) +} function UserResult({ profile, list, + memberships, noBorder, - onAdd, + onChange, }: { profile: AppBskyActorDefs.ProfileViewBasic - list: ListModel + list: AppBskyGraphDefs.ListView + memberships: ListMembersip[] | undefined noBorder: boolean - onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void | undefined + onChange?: ( + type: 'add' | 'remove', + profile: AppBskyActorDefs.ProfileViewBasic, + ) => void | undefined }) { const pal = usePalette('default') + const {_} = useLingui() const [isProcessing, setIsProcessing] = useState(false) - const [isAdded, setIsAdded] = useState(list.isMember(profile.did)) + const membership = React.useMemo( + () => getMembership(memberships, list.uri, profile.did), + [memberships, list.uri, profile.did], + ) + const listMembershipAddMutation = useListMembershipAddMutation() + const listMembershipRemoveMutation = useListMembershipRemoveMutation() - const onPressAdd = useCallback(async () => { + const onToggleMembership = useCallback(async () => { + if (typeof membership === 'undefined') { + return + } setIsProcessing(true) try { - await list.addMember(profile) - Toast.show('Added to list') - setIsAdded(true) - onAdd?.(profile) + if (membership === false) { + await listMembershipAddMutation.mutateAsync({ + listUri: list.uri, + actorDid: profile.did, + }) + Toast.show(_(msg`Added to list`)) + onChange?.('add', profile) + } else { + await listMembershipRemoveMutation.mutateAsync({ + listUri: list.uri, + actorDid: profile.did, + membershipUri: membership, + }) + Toast.show(_(msg`Removed from list`)) + onChange?.('remove', profile) + } } catch (e) { Toast.show(cleanError(e)) } finally { setIsProcessing(false) } - }, [list, profile, setIsProcessing, setIsAdded, onAdd]) + }, [ + _, + list, + profile, + membership, + setIsProcessing, + onChange, + listMembershipAddMutation, + listMembershipRemoveMutation, + ]) return ( <View @@ -228,16 +255,14 @@ function UserResult({ {!!profile.viewer?.followedBy && <View style={s.flexRow} />} </View> <View> - {isAdded ? ( - <FontAwesomeIcon icon="check" /> - ) : isProcessing ? ( + {isProcessing || typeof membership === 'undefined' ? ( <ActivityIndicator /> ) : ( <Button testID={`user-${profile.handle}-addBtn`} type="default" - label="Add" - onPress={onPressAdd} + label={membership === false ? _(msg`Add`) : _(msg`Remove`)} + onPress={onToggleMembership} /> )} </View> diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 5aaa09e87..a3e6fb9e5 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -1,15 +1,15 @@ import React, {useRef, useEffect} from 'react' import {StyleSheet} from 'react-native' import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context' -import {observer} from 'mobx-react-lite' import BottomSheet from '@gorhom/bottom-sheet' -import {useStores} from 'state/index' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' import {usePalette} from 'lib/hooks/usePalette' import {timeout} from 'lib/async/timeout' import {navigate} from '../../../Navigation' import once from 'lodash.once' +import {useModals, useModalControls} from '#/state/modals' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as ProfilePreviewModal from './ProfilePreview' @@ -18,7 +18,7 @@ import * as RepostModal from './Repost' import * as SelfLabelModal from './SelfLabel' import * as CreateOrEditListModal from './CreateOrEditList' import * as UserAddRemoveListsModal from './UserAddRemoveLists' -import * as ListAddUserModal from './ListAddUser' +import * as ListAddUserModal from './ListAddRemoveUsers' import * as AltImageModal from './AltImage' import * as EditImageModal from './AltImage' import * as ReportModal from './report/Modal' @@ -40,26 +40,29 @@ import * as LinkWarningModal from './LinkWarning' const DEFAULT_SNAPPOINTS = ['90%'] const HANDLE_HEIGHT = 24 -export const ModalsContainer = observer(function ModalsContainer() { - const store = useStores() +export function ModalsContainer() { + const {isModalActive, activeModals} = useModals() + const {closeModal} = useModalControls() const bottomSheetRef = useRef<BottomSheet>(null) const pal = usePalette('default') const safeAreaInsets = useSafeAreaInsets() - const activeModal = - store.shell.activeModals[store.shell.activeModals.length - 1] + const activeModal = activeModals[activeModals.length - 1] const navigateOnce = once(navigate) - const onBottomSheetAnimate = (fromIndex: number, toIndex: number) => { - if (activeModal?.name === 'profile-preview' && toIndex === 1) { - // begin loading the profile screen behind the scenes - navigateOnce('Profile', {name: activeModal.did}) - } - } + // It seems like the bottom sheet bugs out when this callback changes. + const onBottomSheetAnimate = useNonReactiveCallback( + (_fromIndex: number, toIndex: number) => { + if (activeModal?.name === 'profile-preview' && toIndex === 1) { + // begin loading the profile screen behind the scenes + navigateOnce('Profile', {name: activeModal.did}) + } + }, + ) const onBottomSheetChange = async (snapPoint: number) => { if (snapPoint === -1) { - store.shell.closeModal() + closeModal() } else if (activeModal?.name === 'profile-preview' && snapPoint === 1) { await navigateOnce('Profile', {name: activeModal.did}) // There is no particular callback for when the view has actually been presented. @@ -67,21 +70,21 @@ export const ModalsContainer = observer(function ModalsContainer() { // It's acceptable because the data is already being fetched + it usually takes longer anyway. // TODO: Figure out why avatar/cover don't always show instantly from cache. await timeout(200) - store.shell.closeModal() + closeModal() } } const onClose = () => { bottomSheetRef.current?.close() - store.shell.closeModal() + closeModal() } useEffect(() => { - if (store.shell.isModalActive) { + if (isModalActive) { bottomSheetRef.current?.expand() } else { bottomSheetRef.current?.close() } - }, [store.shell.isModalActive, bottomSheetRef, activeModal?.name]) + }, [isModalActive, bottomSheetRef, activeModal?.name]) let needsSafeTopInset = false let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS @@ -108,7 +111,7 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'user-add-remove-lists') { snapPoints = UserAddRemoveListsModal.snapPoints element = <UserAddRemoveListsModal.Component {...activeModal} /> - } else if (activeModal?.name === 'list-add-user') { + } else if (activeModal?.name === 'list-add-remove-users') { snapPoints = ListAddUserModal.snapPoints element = <ListAddUserModal.Component {...activeModal} /> } else if (activeModal?.name === 'delete-account') { @@ -184,12 +187,12 @@ export const ModalsContainer = observer(function ModalsContainer() { snapPoints={snapPoints} topInset={topInset} handleHeight={HANDLE_HEIGHT} - index={store.shell.isModalActive ? 0 : -1} + index={isModalActive ? 0 : -1} enablePanDownToClose android_keyboardInputMode="adjustResize" keyboardBlurBehavior="restore" backdropComponent={ - store.shell.isModalActive ? createCustomBackdrop(onClose) : undefined + isModalActive ? createCustomBackdrop(onClose) : undefined } handleIndicatorStyle={{backgroundColor: pal.text.color}} handleStyle={[styles.handle, pal.view]} @@ -198,7 +201,7 @@ export const ModalsContainer = observer(function ModalsContainer() { {element} </BottomSheet> ) -}) +} const styles = StyleSheet.create({ handle: { diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index ede845378..c39ba1f51 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -1,11 +1,11 @@ import React from 'react' import {TouchableWithoutFeedback, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import type {Modal as ModalIface} from 'state/models/ui/shell' +import {useModals, useModalControls} from '#/state/modals' +import type {Modal as ModalIface} from '#/state/modals' import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as ProfilePreviewModal from './ProfilePreview' @@ -13,7 +13,7 @@ import * as ServerInputModal from './ServerInput' import * as ReportModal from './report/Modal' import * as CreateOrEditListModal from './CreateOrEditList' import * as UserAddRemoveLists from './UserAddRemoveLists' -import * as ListAddUserModal from './ListAddUser' +import * as ListAddUserModal from './ListAddRemoveUsers' import * as DeleteAccountModal from './DeleteAccount' import * as RepostModal from './Repost' import * as SelfLabelModal from './SelfLabel' @@ -33,28 +33,29 @@ import * as VerifyEmailModal from './VerifyEmail' import * as ChangeEmailModal from './ChangeEmail' import * as LinkWarningModal from './LinkWarning' -export const ModalsContainer = observer(function ModalsContainer() { - const store = useStores() +export function ModalsContainer() { + const {isModalActive, activeModals} = useModals() - if (!store.shell.isModalActive) { + if (!isModalActive) { return null } return ( <> - {store.shell.activeModals.map((modal, i) => ( + {activeModals.map((modal, i) => ( <Modal key={`modal-${i}`} modal={modal} /> ))} </> ) -}) +} function Modal({modal}: {modal: ModalIface}) { - const store = useStores() + const {isModalActive} = useModals() + const {closeModal} = useModalControls() const pal = usePalette('default') const {isMobile} = useWebMediaQueries() - if (!store.shell.isModalActive) { + if (!isModalActive) { return null } @@ -62,7 +63,7 @@ function Modal({modal}: {modal: ModalIface}) { if (modal.name === 'crop-image' || modal.name === 'edit-image') { return // dont close on mask presses during crop } - store.shell.closeModal() + closeModal() } const onInnerPress = () => { // TODO: can we use prevent default? @@ -84,7 +85,7 @@ function Modal({modal}: {modal: ModalIface}) { element = <CreateOrEditListModal.Component {...modal} /> } else if (modal.name === 'user-add-remove-lists') { element = <UserAddRemoveLists.Component {...modal} /> - } else if (modal.name === 'list-add-user') { + } else if (modal.name === 'list-add-remove-users') { element = <ListAddUserModal.Component {...modal} /> } else if (modal.name === 'crop-image') { element = <CropImageModal.Component {...modal} /> @@ -129,7 +130,10 @@ function Modal({modal}: {modal: ModalIface}) { return ( // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors <TouchableWithoutFeedback onPress={onPressMask}> - <View style={styles.mask}> + <Animated.View + style={styles.mask} + entering={FadeIn.duration(150)} + exiting={FadeOut}> {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */} <TouchableWithoutFeedback onPress={onInnerPress}> <View @@ -142,7 +146,7 @@ function Modal({modal}: {modal: ModalIface}) { {element} </View> </TouchableWithoutFeedback> - </View> + </Animated.View> </TouchableWithoutFeedback> ) } diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx index c01312d69..c117023d4 100644 --- a/src/view/com/modals/ModerationDetails.tsx +++ b/src/view/com/modals/ModerationDetails.tsx @@ -1,7 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {ModerationUI} from '@atproto/api' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {s} from 'lib/styles' import {Text} from '../util/text/Text' @@ -10,6 +9,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' import {listUriToHref} from 'lib/strings/url-helpers' import {Button} from '../util/forms/Button' +import {useModalControls} from '#/state/modals' export const snapPoints = [300] @@ -20,7 +20,7 @@ export function Component({ context: 'account' | 'content' moderation: ModerationUI }) { - const store = useStores() + const {closeModal} = useModalControls() const {isMobile} = useWebMediaQueries() const pal = usePalette('default') @@ -102,7 +102,9 @@ export function Component({ <Button type="primary" style={styles.btn} - onPress={() => store.shell.closeModal()}> + onPress={() => { + closeModal() + }}> <Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}> Okay </Text> diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx index dad02aa5e..edfbf6a82 100644 --- a/src/view/com/modals/ProfilePreview.tsx +++ b/src/view/com/modals/ProfilePreview.tsx @@ -1,27 +1,81 @@ import React, {useState, useEffect} from 'react' import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' +import {AppBskyActorDefs, ModerationOpts, moderateProfile} from '@atproto/api' import {ThemedText} from '../util/text/ThemedText' -import {useStores} from 'state/index' -import {ProfileModel} from 'state/models/content/profile' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {ProfileHeader} from '../profile/ProfileHeader' import {InfoCircleIcon} from 'lib/icons' import {useNavigationState} from '@react-navigation/native' import {s} from 'lib/styles' +import {useModerationOpts} from '#/state/queries/preferences' +import {useProfileQuery} from '#/state/queries/profile' +import {ErrorScreen} from '../util/error/ErrorScreen' +import {CenteredView} from '../util/Views' +import {cleanError} from '#/lib/strings/errors' +import {useProfileShadow} from '#/state/cache/profile-shadow' export const snapPoints = [520, '100%'] -export const Component = observer(function ProfilePreviewImpl({ - did, +export function Component({did}: {did: string}) { + const pal = usePalette('default') + const moderationOpts = useModerationOpts() + const { + data: profile, + error: profileError, + refetch: refetchProfile, + isFetching: isFetchingProfile, + } = useProfileQuery({ + did: did, + }) + + if (isFetchingProfile || !moderationOpts) { + return ( + <CenteredView style={[pal.view, s.flex1]}> + <ProfileHeader + profile={null} + moderation={null} + isProfilePreview={true} + /> + </CenteredView> + ) + } + if (profileError) { + return ( + <ErrorScreen + title="Oops!" + message={cleanError(profileError)} + onPressTryAgain={refetchProfile} + /> + ) + } + if (profile && moderationOpts) { + return <ComponentLoaded profile={profile} moderationOpts={moderationOpts} /> + } + // should never happen + return ( + <ErrorScreen + title="Oops!" + message="Something went wrong and we're not sure what." + onPressTryAgain={refetchProfile} + /> + ) +} + +function ComponentLoaded({ + profile: profileUnshadowed, + moderationOpts, }: { - did: string + profile: AppBskyActorDefs.ProfileViewDetailed + moderationOpts: ModerationOpts }) { - const store = useStores() const pal = usePalette('default') - const [model] = useState(new ProfileModel(store, {actor: did})) + const profile = useProfileShadow(profileUnshadowed) const {screen} = useAnalytics() + const moderation = React.useMemo( + () => moderateProfile(profile, moderationOpts), + [profile, moderationOpts], + ) // track the navigator state to detect if a page-load occurred const navState = useNavigationState(state => state) @@ -30,16 +84,15 @@ export const Component = observer(function ProfilePreviewImpl({ useEffect(() => { screen('Profile:Preview') - model.setup() - }, [model, screen]) + }, [screen]) return ( <View testID="profilePreview" style={[pal.view, s.flex1]}> <View style={[styles.headerWrapper]}> <ProfileHeader - view={model} + profile={profile} + moderation={moderation} hideBackButton - onRefreshAll={() => {}} isProfilePreview /> </View> @@ -59,7 +112,7 @@ export const Component = observer(function ProfilePreviewImpl({ </View> </View> ) -}) +} const styles = StyleSheet.create({ headerWrapper: { diff --git a/src/view/com/modals/Repost.tsx b/src/view/com/modals/Repost.tsx index b1862ecbd..a72da29b4 100644 --- a/src/view/com/modals/Repost.tsx +++ b/src/view/com/modals/Repost.tsx @@ -1,12 +1,14 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' import LinearGradient from 'react-native-linear-gradient' -import {useStores} from 'state/index' import {s, colors, gradients} from 'lib/styles' import {Text} from '../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {RepostIcon} from 'lib/icons' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export const snapPoints = [250] @@ -20,10 +22,11 @@ export function Component({ isReposted: boolean // TODO: Add author into component }) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() + const {closeModal} = useModalControls() const onPress = async () => { - store.shell.closeModal() + closeModal() } return ( @@ -38,7 +41,7 @@ export function Component({ accessibilityHint={isReposted ? 'Remove repost' : 'Repost '}> <RepostIcon strokeWidth={2} size={24} style={s.blue3} /> <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}> - {!isReposted ? 'Repost' : 'Undo repost'} + <Trans>{!isReposted ? 'Repost' : 'Undo repost'}</Trans> </Text> </TouchableOpacity> <TouchableOpacity @@ -46,11 +49,11 @@ export function Component({ style={[styles.actionBtn]} onPress={onQuote} accessibilityRole="button" - accessibilityLabel="Quote post" + accessibilityLabel={_(msg`Quote post`)} accessibilityHint=""> <FontAwesomeIcon icon="quote-left" size={24} style={s.blue3} /> <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}> - Quote Post + <Trans>Quote Post</Trans> </Text> </TouchableOpacity> </View> @@ -58,7 +61,7 @@ export function Component({ testID="cancelBtn" onPress={onPress} accessibilityRole="button" - accessibilityLabel="Cancel quote post" + accessibilityLabel={_(msg`Cancel quote post`)} accessibilityHint="" onAccessibilityEscape={onPress}> <LinearGradient @@ -66,7 +69,9 @@ export function Component({ start={{x: 0, y: 0}} end={{x: 1, y: 1}} style={[styles.btn]}> - <Text style={[s.white, s.bold, s.f18]}>Cancel</Text> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Cancel</Trans> + </Text> </LinearGradient> </TouchableOpacity> </View> diff --git a/src/view/com/modals/SelfLabel.tsx b/src/view/com/modals/SelfLabel.tsx index 820f2895b..092dd2d32 100644 --- a/src/view/com/modals/SelfLabel.tsx +++ b/src/view/com/modals/SelfLabel.tsx @@ -1,8 +1,6 @@ import React, {useState} from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {Text} from '../util/text/Text' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' @@ -10,12 +8,15 @@ import {isWeb} from 'platform/detection' import {Button} from '../util/forms/Button' import {SelectableBtn} from '../util/forms/SelectableBtn' import {ScrollView} from 'view/com/modals/util' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn'] export const snapPoints = ['50%'] -export const Component = observer(function Component({ +export function Component({ labels, hasMedia, onChange, @@ -25,9 +26,10 @@ export const Component = observer(function Component({ onChange: (labels: string[]) => void }) { const pal = usePalette('default') - const store = useStores() + const {closeModal} = useModalControls() const {isMobile} = useWebMediaQueries() const [selected, setSelected] = useState(labels) + const {_} = useLingui() const toggleAdultLabel = (label: string) => { const hadLabel = selected.includes(label) @@ -51,7 +53,7 @@ export const Component = observer(function Component({ <View testID="selfLabelModal" style={[pal.view, styles.container]}> <View style={styles.titleSection}> <Text type="title-lg" style={[pal.text, styles.title]}> - Add a content warning + <Trans>Add a content warning</Trans> </Text> </View> @@ -70,7 +72,7 @@ export const Component = observer(function Component({ paddingBottom: 8, }}> <Text type="title" style={pal.text}> - Adult Content + <Trans>Adult Content</Trans> </Text> {hasAdultSelection ? ( <Button @@ -78,7 +80,7 @@ export const Component = observer(function Component({ onPress={removeAdultLabel} style={{paddingTop: 0, paddingBottom: 0, paddingRight: 0}}> <Text type="md" style={pal.link}> - Remove + <Trans>Remove</Trans> </Text> </Button> ) : null} @@ -116,23 +118,25 @@ export const Component = observer(function Component({ <Text style={[pal.text, styles.adultExplainer]}> {selected.includes('sexual') ? ( - <>Pictures meant for adults.</> + <Trans>Pictures meant for adults.</Trans> ) : selected.includes('nudity') ? ( - <>Artistic or non-erotic nudity.</> + <Trans>Artistic or non-erotic nudity.</Trans> ) : selected.includes('porn') ? ( - <>Sexual activity or erotic nudity.</> + <Trans>Sexual activity or erotic nudity.</Trans> ) : ( - <>If none are selected, suitable for all ages.</> + <Trans>If none are selected, suitable for all ages.</Trans> )} </Text> </> ) : ( <View> <Text style={[pal.textLight]}> - <Text type="md-bold" style={[pal.textLight]}> - Not Applicable + <Text type="md-bold" style={[pal.textLight, s.mr5]}> + <Trans>Not Applicable.</Trans> </Text> - . This warning is only available for posts with media attached. + <Trans> + This warning is only available for posts with media attached. + </Trans> </Text> </View> )} @@ -143,18 +147,20 @@ export const Component = observer(function Component({ <TouchableOpacity testID="confirmBtn" onPress={() => { - store.shell.closeModal() + closeModal() }} style={styles.btn} accessibilityRole="button" - accessibilityLabel="Confirm" + accessibilityLabel={_(msg`Confirm`)} accessibilityHint=""> - <Text style={[s.white, s.bold, s.f18]}>Done</Text> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Done</Trans> + </Text> </TouchableOpacity> </View> </View> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx index 13b21fe22..b30293859 100644 --- a/src/view/com/modals/ServerInput.tsx +++ b/src/view/com/modals/ServerInput.tsx @@ -6,33 +6,36 @@ import { } from '@fortawesome/react-native-fontawesome' import {ScrollView, TextInput} from './util' import {Text} from '../util/text/Text' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' -import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index' +import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants' import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export const snapPoints = ['80%'] export function Component({onSelect}: {onSelect: (url: string) => void}) { const theme = useTheme() const pal = usePalette('default') - const store = useStores() const [customUrl, setCustomUrl] = useState<string>('') + const {_} = useLingui() + const {closeModal} = useModalControls() const doSelect = (url: string) => { if (!url.startsWith('http://') && !url.startsWith('https://')) { url = `https://${url}` } - store.shell.closeModal() + closeModal() onSelect(url) } return ( <View style={[pal.view, s.flex1]} testID="serverInputModal"> <Text type="2xl-bold" style={[pal.text, s.textCenter]}> - Choose Service + <Trans>Choose Service</Trans> </Text> <ScrollView style={styles.inner}> <View style={styles.group}> @@ -43,7 +46,9 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { style={styles.btn} onPress={() => doSelect(LOCAL_DEV_SERVICE)} accessibilityRole="button"> - <Text style={styles.btnText}>Local dev server</Text> + <Text style={styles.btnText}> + <Trans>Local dev server</Trans> + </Text> <FontAwesomeIcon icon="arrow-right" style={s.white as FontAwesomeIconStyle} @@ -53,7 +58,9 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { style={styles.btn} onPress={() => doSelect(STAGING_SERVICE)} accessibilityRole="button"> - <Text style={styles.btnText}>Staging</Text> + <Text style={styles.btnText}> + <Trans>Staging</Trans> + </Text> <FontAwesomeIcon icon="arrow-right" style={s.white as FontAwesomeIconStyle} @@ -65,9 +72,11 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { style={styles.btn} onPress={() => doSelect(PROD_SERVICE)} accessibilityRole="button" - accessibilityLabel="Select Bluesky Social" + accessibilityLabel={_(msg`Select Bluesky Social`)} accessibilityHint="Sets Bluesky Social as your service provider"> - <Text style={styles.btnText}>Bluesky.Social</Text> + <Text style={styles.btnText}> + <Trans>Bluesky.Social</Trans> + </Text> <FontAwesomeIcon icon="arrow-right" style={s.white as FontAwesomeIconStyle} @@ -75,7 +84,9 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { </TouchableOpacity> </View> <View style={styles.group}> - <Text style={[pal.text, styles.label]}>Other service</Text> + <Text style={[pal.text, styles.label]}> + <Trans>Other service</Trans> + </Text> <View style={s.flexRow}> <TextInput testID="customServerTextInput" @@ -88,7 +99,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { keyboardAppearance={theme.colorScheme} value={customUrl} onChangeText={setCustomUrl} - accessibilityLabel="Custom domain" + accessibilityLabel={_(msg`Custom domain`)} // TODO: Simplify this wording further to be understandable by everyone accessibilityHint="Use your domain as your Bluesky client service provider" /> diff --git a/src/view/com/modals/SwitchAccount.tsx b/src/view/com/modals/SwitchAccount.tsx index d5fa32692..38e1ce1e0 100644 --- a/src/view/com/modals/SwitchAccount.tsx +++ b/src/view/com/modals/SwitchAccount.tsx @@ -6,7 +6,6 @@ import { View, } from 'react-native' import {Text} from '../util/text/Text' -import {useStores} from 'state/index' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' @@ -17,88 +16,114 @@ import {Link} from '../util/Link' import {makeProfileLink} from 'lib/routes/links' import {BottomSheetScrollView} from '@gorhom/bottom-sheet' import {Haptics} from 'lib/haptics' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSession, useSessionApi, SessionAccount} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' export const snapPoints = ['40%', '90%'] -export function Component({}: {}) { +function SwitchAccountCard({account}: {account: SessionAccount}) { const pal = usePalette('default') + const {_} = useLingui() const {track} = useAnalytics() + const {isSwitchingAccounts, currentAccount} = useSession() + const {logout} = useSessionApi() + const {data: profile} = useProfileQuery({did: account.did}) + const isCurrentAccount = account.did === currentAccount?.did + const {onPressSwitchAccount} = useAccountSwitcher() + + const onPressSignout = React.useCallback(() => { + track('Settings:SignOutButtonClicked') + logout() + }, [track, logout]) - const store = useStores() - const [isSwitching, _, onPressSwitchAccount] = useAccountSwitcher() + const contents = ( + <View style={[pal.view, styles.linkCard]}> + <View style={styles.avi}> + <UserAvatar size={40} avatar={profile?.avatar} /> + </View> + <View style={[s.flex1]}> + <Text type="md-bold" style={pal.text} numberOfLines={1}> + {profile?.displayName || account?.handle} + </Text> + <Text type="sm" style={pal.textLight} numberOfLines={1}> + {account?.handle} + </Text> + </View> + + {isCurrentAccount ? ( + <TouchableOpacity + testID="signOutBtn" + onPress={isSwitchingAccounts ? undefined : onPressSignout} + accessibilityRole="button" + accessibilityLabel={_(msg`Sign out`)} + accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}> + <Text type="lg" style={pal.link}> + <Trans>Sign out</Trans> + </Text> + </TouchableOpacity> + ) : ( + <AccountDropdownBtn account={account} /> + )} + </View> + ) + + return isCurrentAccount ? ( + <Link + href={makeProfileLink({ + did: currentAccount.did, + handle: currentAccount.handle, + })} + title={_(msg`Your profile`)} + noFeedback> + {contents} + </Link> + ) : ( + <TouchableOpacity + testID={`switchToAccountBtn-${account.handle}`} + key={account.did} + style={[isSwitchingAccounts && styles.dimmed]} + onPress={ + isSwitchingAccounts ? undefined : () => onPressSwitchAccount(account) + } + accessibilityRole="button" + accessibilityLabel={`Switch to ${account.handle}`} + accessibilityHint="Switches the account you are logged in to"> + {contents} + </TouchableOpacity> + ) +} + +export function Component({}: {}) { + const pal = usePalette('default') + const {isSwitchingAccounts, currentAccount, accounts} = useSession() React.useEffect(() => { Haptics.default() }) - const onPressSignout = React.useCallback(() => { - track('Settings:SignOutButtonClicked') - store.session.logout() - }, [track, store]) - return ( <BottomSheetScrollView style={[styles.container, pal.view]} contentContainerStyle={[styles.innerContainer, pal.view]}> <Text type="title-xl" style={[styles.title, pal.text]}> - Switch Account + <Trans>Switch Account</Trans> </Text> - {isSwitching ? ( + + {isSwitchingAccounts || !currentAccount ? ( <View style={[pal.view, styles.linkCard]}> <ActivityIndicator /> </View> ) : ( - <Link href={makeProfileLink(store.me)} title="Your profile" noFeedback> - <View style={[pal.view, styles.linkCard]}> - <View style={styles.avi}> - <UserAvatar size={40} avatar={store.me.avatar} /> - </View> - <View style={[s.flex1]}> - <Text type="md-bold" style={pal.text} numberOfLines={1}> - {store.me.displayName || store.me.handle} - </Text> - <Text type="sm" style={pal.textLight} numberOfLines={1}> - {store.me.handle} - </Text> - </View> - <TouchableOpacity - testID="signOutBtn" - onPress={isSwitching ? undefined : onPressSignout} - accessibilityRole="button" - accessibilityLabel="Sign out" - accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}> - <Text type="lg" style={pal.link}> - Sign out - </Text> - </TouchableOpacity> - </View> - </Link> + <SwitchAccountCard account={currentAccount} /> )} - {store.session.switchableAccounts.map(account => ( - <TouchableOpacity - testID={`switchToAccountBtn-${account.handle}`} - key={account.did} - style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]} - onPress={ - isSwitching ? undefined : () => onPressSwitchAccount(account) - } - accessibilityRole="button" - accessibilityLabel={`Switch to ${account.handle}`} - accessibilityHint="Switches the account you are logged in to"> - <View style={styles.avi}> - <UserAvatar size={40} avatar={account.aviUrl} /> - </View> - <View style={[s.flex1]}> - <Text type="md-bold" style={pal.text}> - {account.displayName || account.handle} - </Text> - <Text type="sm" style={pal.textLight}> - {account.handle} - </Text> - </View> - <AccountDropdownBtn handle={account.handle} /> - </TouchableOpacity> - ))} + + {accounts + .filter(a => a.did !== currentAccount?.did) + .map(account => ( + <SwitchAccountCard key={account.did} account={account} /> + ))} </BottomSheetScrollView> ) } diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx index aeec2e87f..8c3dc8bb7 100644 --- a/src/view/com/modals/UserAddRemoveLists.tsx +++ b/src/view/com/modals/UserAddRemoveLists.tsx @@ -1,30 +1,32 @@ import React, {useCallback} from 'react' -import {observer} from 'mobx-react-lite' -import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' -import {ListsList} from '../lists/ListsList' -import {ListsListModel} from 'state/models/lists/lists-list' -import {ListMembershipModel} from 'state/models/content/list-membership' +import {MyLists} from '../lists/MyLists' import {Button} from '../util/forms/Button' import * as Toast from '../util/Toast' -import {useStores} from 'state/index' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb, isAndroid} from 'platform/detection' -import isEqual from 'lodash.isequal' -import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import { + useDangerousListMembershipsQuery, + getMembership, + ListMembersip, + useListMembershipAddMutation, + useListMembershipRemoveMutation, +} from '#/state/queries/list-memberships' +import {cleanError} from '#/lib/strings/errors' +import {useSession} from '#/state/session' export const snapPoints = ['fullscreen'] -export const Component = observer(function UserAddRemoveListsImpl({ +export function Component({ subject, displayName, onAdd, @@ -35,191 +37,161 @@ export const Component = observer(function UserAddRemoveListsImpl({ onAdd?: (listUri: string) => void onRemove?: (listUri: string) => void }) { - const store = useStores() + const {closeModal} = useModalControls() const pal = usePalette('default') - const palPrimary = usePalette('primary') - const palInverted = usePalette('inverted') - const [originalSelections, setOriginalSelections] = React.useState<string[]>( - [], - ) - const [selected, setSelected] = React.useState<string[]>([]) - const [membershipsLoaded, setMembershipsLoaded] = React.useState(false) + const {_} = useLingui() + const {data: memberships} = useDangerousListMembershipsQuery() - const listsList: ListsListModel = React.useMemo( - () => new ListsListModel(store, store.me.did), - [store], - ) - const memberships: ListMembershipModel = React.useMemo( - () => new ListMembershipModel(store, subject), - [store, subject], - ) - React.useEffect(() => { - listsList.refresh() - memberships.fetch().then( - () => { - const ids = memberships.memberships.map(m => m.value.list) - setOriginalSelections(ids) - setSelected(ids) - setMembershipsLoaded(true) - }, - err => { - logger.error('Failed to fetch memberships', {error: err}) - }, - ) - }, [memberships, listsList, store, setSelected, setMembershipsLoaded]) - - const onPressCancel = useCallback(() => { - store.shell.closeModal() - }, [store]) - - const onPressSave = useCallback(async () => { - let changes - try { - changes = await memberships.updateTo(selected) - } catch (err) { - logger.error('Failed to update memberships', {error: err}) - return - } - Toast.show('Lists updated') - for (const uri of changes.added) { - onAdd?.(uri) - } - for (const uri of changes.removed) { - onRemove?.(uri) - } - store.shell.closeModal() - }, [store, selected, memberships, onAdd, onRemove]) - - const onToggleSelected = useCallback( - (uri: string) => { - if (selected.includes(uri)) { - setSelected(selected.filter(uri2 => uri2 !== uri)) - } else { - setSelected([...selected, uri]) - } - }, - [selected, setSelected], - ) - - const renderItem = useCallback( - (list: GraphDefs.ListView, index: number) => { - const isSelected = selected.includes(list.uri) - return ( - <Pressable - testID={`toggleBtn-${list.name}`} - style={[ - styles.listItem, - pal.border, - { - opacity: membershipsLoaded ? 1 : 0.5, - borderTopWidth: index === 0 ? 0 : 1, - }, - ]} - accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${ - list.name - }`} - accessibilityHint="" - disabled={!membershipsLoaded} - onPress={() => onToggleSelected(list.uri)}> - <View style={styles.listItemAvi}> - <UserAvatar size={40} avatar={list.avatar} /> - </View> - <View style={styles.listItemContent}> - <Text - type="lg" - style={[s.bold, pal.text]} - numberOfLines={1} - lineHeight={1.2}> - {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 === store.me.did - ? 'you' - : sanitizeHandle(list.creator.handle, '@')} - </Text> - </View> - {membershipsLoaded && ( - <View - style={ - isSelected - ? [styles.checkbox, palPrimary.border, palPrimary.view] - : [styles.checkbox, pal.borderDark] - }> - {isSelected && ( - <FontAwesomeIcon - icon="check" - style={palInverted.text as FontAwesomeIconStyle} - /> - )} - </View> - )} - </Pressable> - ) - }, - [ - pal, - palPrimary, - palInverted, - onToggleSelected, - selected, - store.me.did, - membershipsLoaded, - ], - ) - - // Only show changes button if there are some items on the list to choose from AND user has made changes in selection - const canSaveChanges = - !listsList.isEmpty && !isEqual(selected, originalSelections) + const onPressDone = useCallback(() => { + closeModal() + }, [closeModal]) return ( <View testID="userAddRemoveListsModal" style={s.hContentRegion}> <Text style={[styles.title, pal.text]}> - Update {displayName} in Lists + <Trans>Update {displayName} in Lists</Trans> </Text> - <ListsList - listsList={listsList} + <MyLists + filter="all" inline - renderItem={renderItem} + renderItem={(list, index) => ( + <ListItem + index={index} + list={list} + memberships={memberships} + subject={subject} + onAdd={onAdd} + onRemove={onRemove} + /> + )} style={[styles.list, pal.border]} /> <View style={[styles.btns, pal.border]}> <Button - testID="cancelBtn" + testID="doneBtn" type="default" - onPress={onPressCancel} + onPress={onPressDone} style={styles.footerBtn} - accessibilityLabel="Cancel" + accessibilityLabel={_(msg`Done`)} accessibilityHint="" - onAccessibilityEscape={onPressCancel} - label="Cancel" + onAccessibilityEscape={onPressDone} + label="Done" /> - {canSaveChanges && ( + </View> + </View> + ) +} + +function ListItem({ + index, + list, + memberships, + subject, + onAdd, + onRemove, +}: { + index: number + list: GraphDefs.ListView + memberships: ListMembersip[] | undefined + subject: string + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void +}) { + const pal = usePalette('default') + const {_} = useLingui() + const {currentAccount} = useSession() + const [isProcessing, setIsProcessing] = React.useState(false) + const membership = React.useMemo( + () => getMembership(memberships, list.uri, subject), + [memberships, list.uri, subject], + ) + const listMembershipAddMutation = useListMembershipAddMutation() + const listMembershipRemoveMutation = useListMembershipRemoveMutation() + + const onToggleMembership = useCallback(async () => { + if (typeof membership === 'undefined') { + return + } + setIsProcessing(true) + try { + if (membership === false) { + await listMembershipAddMutation.mutateAsync({ + listUri: list.uri, + actorDid: subject, + }) + Toast.show(_(msg`Added to list`)) + onAdd?.(list.uri) + } else { + await listMembershipRemoveMutation.mutateAsync({ + listUri: list.uri, + actorDid: subject, + membershipUri: membership, + }) + Toast.show(_(msg`Removed from list`)) + onRemove?.(list.uri) + } + } catch (e) { + Toast.show(cleanError(e)) + } finally { + setIsProcessing(false) + } + }, [ + _, + list, + subject, + membership, + setIsProcessing, + onAdd, + onRemove, + listMembershipAddMutation, + listMembershipRemoveMutation, + ]) + + return ( + <View + testID={`toggleBtn-${list.name}`} + style={[ + styles.listItem, + pal.border, + { + borderTopWidth: index === 0 ? 0 : 1, + }, + ]}> + <View style={styles.listItemAvi}> + <UserAvatar size={40} avatar={list.avatar} /> + </View> + <View style={styles.listItemContent}> + <Text + type="lg" + style={[s.bold, pal.text]} + numberOfLines={1} + lineHeight={1.2}> + {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, '@')} + </Text> + </View> + <View> + {isProcessing || typeof membership === 'undefined' ? ( + <ActivityIndicator /> + ) : ( <Button - testID="saveBtn" - type="primary" - onPress={onPressSave} - style={styles.footerBtn} - accessibilityLabel="Save changes" - accessibilityHint="" - onAccessibilityEscape={onPressSave} - label="Save Changes" + testID={`user-${subject}-addBtn`} + type="default" + label={membership === false ? _(msg`Add`) : _(msg`Remove`)} + onPress={onToggleMembership} /> )} - - {(listsList.isLoading || !membershipsLoaded) && ( - <View style={styles.loadingContainer}> - <ActivityIndicator /> - </View> - )} </View> </View> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx index 9fe8811b0..4376a3e45 100644 --- a/src/view/com/modals/VerifyEmail.tsx +++ b/src/view/com/modals/VerifyEmail.tsx @@ -8,18 +8,20 @@ import { } from 'react-native' import {Svg, Circle, Path} from 'react-native-svg' import {ScrollView, TextInput} from './util' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import {ErrorMessage} from '../util/error/ErrorMessage' import * as Toast from '../util/Toast' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {cleanError} from 'lib/strings/errors' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useSession, useSessionApi, getAgent} from '#/state/session' export const snapPoints = ['90%'] @@ -29,13 +31,11 @@ enum Stages { ConfirmCode, } -export const Component = observer(function Component({ - showReminder, -}: { - showReminder?: boolean -}) { +export function Component({showReminder}: {showReminder?: boolean}) { const pal = usePalette('default') - const store = useStores() + const {currentAccount} = useSession() + const {updateCurrentAccount} = useSessionApi() + const {_} = useLingui() const [stage, setStage] = useState<Stages>( showReminder ? Stages.Reminder : Stages.Email, ) @@ -43,12 +43,13 @@ export const Component = observer(function Component({ const [isProcessing, setIsProcessing] = useState<boolean>(false) const [error, setError] = useState<string>('') const {isMobile} = useWebMediaQueries() + const {openModal, closeModal} = useModalControls() const onSendEmail = async () => { setError('') setIsProcessing(true) try { - await store.agent.com.atproto.server.requestEmailConfirmation() + await getAgent().com.atproto.server.requestEmailConfirmation() setStage(Stages.ConfirmCode) } catch (e) { setError(cleanError(String(e))) @@ -61,13 +62,13 @@ export const Component = observer(function Component({ setError('') setIsProcessing(true) try { - await store.agent.com.atproto.server.confirmEmail({ - email: (store.session.currentSession?.email || '').trim(), + await getAgent().com.atproto.server.confirmEmail({ + email: (currentAccount?.email || '').trim(), token: confirmationCode.trim(), }) - store.session.updateLocalAccountData({emailConfirmed: true}) + updateCurrentAccount({emailConfirmed: true}) Toast.show('Email verified') - store.shell.closeModal() + closeModal() } catch (e) { setError(cleanError(String(e))) } finally { @@ -76,8 +77,8 @@ export const Component = observer(function Component({ } const onEmailIncorrect = () => { - store.shell.closeModal() - store.shell.openModal({name: 'change-email'}) + closeModal() + openModal({name: 'change-email'}) } return ( @@ -96,21 +97,20 @@ export const Component = observer(function Component({ <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> {stage === Stages.Reminder ? ( - <> + <Trans> Your email has not yet been verified. This is an important security step which we recommend. - </> + </Trans> ) : stage === Stages.Email ? ( - <> + <Trans> This is important in case you ever need to change your email or reset your password. - </> + </Trans> ) : stage === Stages.ConfirmCode ? ( - <> - An email has been sent to{' '} - {store.session.currentSession?.email || ''}. It includes a - confirmation code which you can enter below. - </> + <Trans> + An email has been sent to {currentAccount?.email || ''}. It + includes a confirmation code which you can enter below. + </Trans> ) : ( '' )} @@ -125,12 +125,12 @@ export const Component = observer(function Component({ size={16} /> <Text type="xl-medium" style={[pal.text, s.flex1, {minWidth: 0}]}> - {store.session.currentSession?.email || ''} + {currentAccount?.email || ''} </Text> </View> <Pressable accessibilityRole="link" - accessibilityLabel="Change my email" + accessibilityLabel={_(msg`Change my email`)} accessibilityHint="" onPress={onEmailIncorrect} style={styles.changeEmailLink}> @@ -148,7 +148,7 @@ export const Component = observer(function Component({ value={confirmationCode} onChangeText={setConfirmationCode} accessible={true} - accessibilityLabel="Confirmation code" + accessibilityLabel={_(msg`Confirmation code`)} accessibilityHint="" autoCapitalize="none" autoComplete="off" @@ -172,7 +172,7 @@ export const Component = observer(function Component({ testID="getStartedBtn" type="primary" onPress={() => setStage(Stages.Email)} - accessibilityLabel="Get Started" + accessibilityLabel={_(msg`Get Started`)} accessibilityHint="" label="Get Started" labelContainerStyle={{justifyContent: 'center', padding: 4}} @@ -185,7 +185,7 @@ export const Component = observer(function Component({ testID="sendEmailBtn" type="primary" onPress={onSendEmail} - accessibilityLabel="Send Confirmation Email" + accessibilityLabel={_(msg`Send Confirmation Email`)} accessibilityHint="" label="Send Confirmation Email" labelContainerStyle={{ @@ -197,7 +197,7 @@ export const Component = observer(function Component({ <Button testID="haveCodeBtn" type="default" - accessibilityLabel="I have a code" + accessibilityLabel={_(msg`I have a code`)} accessibilityHint="" label="I have a confirmation code" labelContainerStyle={{ @@ -214,7 +214,7 @@ export const Component = observer(function Component({ testID="confirmBtn" type="primary" onPress={onConfirm} - accessibilityLabel="Confirm" + accessibilityLabel={_(msg`Confirm`)} accessibilityHint="" label="Confirm" labelContainerStyle={{justifyContent: 'center', padding: 4}} @@ -224,7 +224,9 @@ export const Component = observer(function Component({ <Button testID="cancelBtn" type="default" - onPress={() => store.shell.closeModal()} + onPress={() => { + closeModal() + }} accessibilityLabel={ stage === Stages.Reminder ? 'Not right now' : 'Cancel' } @@ -239,7 +241,7 @@ export const Component = observer(function Component({ </ScrollView> </SafeAreaView> ) -}) +} function ReminderIllustration() { const pal = usePalette('default') diff --git a/src/view/com/modals/Waitlist.tsx b/src/view/com/modals/Waitlist.tsx index 0fb371fe4..a31545c0a 100644 --- a/src/view/com/modals/Waitlist.tsx +++ b/src/view/com/modals/Waitlist.tsx @@ -12,19 +12,22 @@ import { } from '@fortawesome/react-native-fontawesome' import LinearGradient from 'react-native-linear-gradient' import {Text} from '../util/text/Text' -import {useStores} from 'state/index' import {s, gradients} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {ErrorMessage} from '../util/error/ErrorMessage' import {cleanError} from 'lib/strings/errors' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export const snapPoints = ['80%'] export function Component({}: {}) { const pal = usePalette('default') const theme = useTheme() - const store = useStores() + const {_} = useLingui() + const {closeModal} = useModalControls() const [email, setEmail] = React.useState<string>('') const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false) const [isProcessing, setIsProcessing] = React.useState<boolean>(false) @@ -54,19 +57,21 @@ export function Component({}: {}) { setIsProcessing(false) } const onCancel = () => { - store.shell.closeModal() + closeModal() } return ( <View style={[styles.container, pal.view]}> <View style={[styles.innerContainer, pal.view]}> <Text type="title-xl" style={[styles.title, pal.text]}> - Join the waitlist + <Trans>Join the waitlist</Trans> </Text> <Text type="lg" style={[styles.description, pal.text]}> - Bluesky uses invites to build a healthier community. If you don't know - anybody with an invite, you can sign up for the waitlist and we'll - send one soon. + <Trans> + Bluesky uses invites to build a healthier community. If you don't + know anybody with an invite, you can sign up for the waitlist and + we'll send one soon. + </Trans> </Text> <TextInput style={[styles.textInput, pal.borderDark, pal.text, s.mb10, s.mt10]} @@ -80,7 +85,7 @@ export function Component({}: {}) { onSubmitEditing={onPressSignup} enterKeyHint="done" accessible={true} - accessibilityLabel="Email" + accessibilityLabel={_(msg`Email`)} accessibilityHint="Input your email to get on the Bluesky waitlist" /> {error ? ( @@ -99,7 +104,9 @@ export function Component({}: {}) { style={pal.text as FontAwesomeIconStyle} /> <Text style={[s.ml10, pal.text]}> - Your email has been saved! We'll be in touch soon. + <Trans> + Your email has been saved! We'll be in touch soon. + </Trans> </Text> </View> ) : ( @@ -114,7 +121,7 @@ export function Component({}: {}) { end={{x: 1, y: 1}} style={[styles.btn]}> <Text type="button-lg" style={[s.white, s.bold]}> - Join Waitlist + <Trans>Join Waitlist</Trans> </Text> </LinearGradient> </TouchableOpacity> @@ -122,11 +129,11 @@ export function Component({}: {}) { style={[styles.btn, s.mt10]} onPress={onCancel} accessibilityRole="button" - accessibilityLabel="Cancel waitlist signup" + accessibilityLabel={_(msg`Cancel waitlist signup`)} accessibilityHint={`Exits signing up for waitlist with ${email}`} onAccessibilityEscape={onCancel}> <Text type="button-lg" style={pal.textLight}> - Cancel + <Trans>Cancel</Trans> </Text> </TouchableOpacity> </> diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx index 8e35201d1..6f094a1fd 100644 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -7,10 +7,12 @@ import {Text} from 'view/com/util/text/Text' import {Dimensions} from 'lib/media/types' import {getDataUriSize} from 'lib/media/util' import {s, gradients} from 'lib/styles' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons' import {Image as RNImage} from 'react-native-image-crop-picker' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' enum AspectRatio { Square = 'square', @@ -33,8 +35,9 @@ export function Component({ uri: string onSelect: (img?: RNImage) => void }) { - const store = useStores() + const {closeModal} = useModalControls() const pal = usePalette('default') + const {_} = useLingui() const [as, setAs] = React.useState<AspectRatio>(AspectRatio.Square) const [scale, setScale] = React.useState<number>(1) const editorRef = React.useRef<ImageEditor>(null) @@ -43,7 +46,7 @@ export function Component({ const onPressCancel = () => { onSelect(undefined) - store.shell.closeModal() + closeModal() } const onPressDone = () => { const canvas = editorRef.current?.getImageScaledToCanvas() @@ -59,7 +62,7 @@ export function Component({ } else { onSelect(undefined) } - store.shell.closeModal() + closeModal() } let cropperStyle @@ -96,7 +99,7 @@ export function Component({ <TouchableOpacity onPress={doSetAs(AspectRatio.Wide)} accessibilityRole="button" - accessibilityLabel="Wide" + accessibilityLabel={_(msg`Wide`)} accessibilityHint="Sets image aspect ratio to wide"> <RectWideIcon size={24} @@ -106,7 +109,7 @@ export function Component({ <TouchableOpacity onPress={doSetAs(AspectRatio.Tall)} accessibilityRole="button" - accessibilityLabel="Tall" + accessibilityLabel={_(msg`Tall`)} accessibilityHint="Sets image aspect ratio to tall"> <RectTallIcon size={24} @@ -116,7 +119,7 @@ export function Component({ <TouchableOpacity onPress={doSetAs(AspectRatio.Square)} accessibilityRole="button" - accessibilityLabel="Square" + accessibilityLabel={_(msg`Square`)} accessibilityHint="Sets image aspect ratio to square"> <SquareIcon size={24} @@ -128,7 +131,7 @@ export function Component({ <TouchableOpacity onPress={onPressCancel} accessibilityRole="button" - accessibilityLabel="Cancel image crop" + accessibilityLabel={_(msg`Cancel image crop`)} accessibilityHint="Exits image cropping process"> <Text type="xl" style={pal.link}> Cancel @@ -138,7 +141,7 @@ export function Component({ <TouchableOpacity onPress={onPressDone} accessibilityRole="button" - accessibilityLabel="Save image crop" + accessibilityLabel={_(msg`Save image crop`)} accessibilityHint="Saves image crop settings"> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} @@ -146,7 +149,7 @@ export function Component({ end={{x: 1, y: 1}} style={[styles.btn]}> <Text type="xl-medium" style={s.white}> - Done + <Trans>Done</Trans> </Text> </LinearGradient> </TouchableOpacity> diff --git a/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx b/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx index c2d0c222a..91e11a19c 100644 --- a/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx +++ b/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx @@ -4,6 +4,8 @@ import LinearGradient from 'react-native-linear-gradient' import {s, colors, gradients} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export const ConfirmLanguagesButton = ({ onPress, @@ -13,6 +15,7 @@ export const ConfirmLanguagesButton = ({ extraText?: string }) => { const pal = usePalette('default') + const {_} = useLingui() const {isMobile} = useWebMediaQueries() return ( <View @@ -28,14 +31,16 @@ export const ConfirmLanguagesButton = ({ testID="confirmContentLanguagesBtn" onPress={onPress} accessibilityRole="button" - accessibilityLabel="Confirm content language settings" + accessibilityLabel={_(msg`Confirm content language settings`)} accessibilityHint=""> <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]}>Done{extraText}</Text> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Done{extraText}</Trans> + </Text> </LinearGradient> </Pressable> </View> diff --git a/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx b/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx index 910522f90..b8c125b65 100644 --- a/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx +++ b/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx @@ -1,7 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {ScrollView} from '../util' -import {useStores} from 'state/index' import {Text} from '../../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' @@ -9,16 +8,24 @@ import {deviceLocales} from 'platform/detection' import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages' import {LanguageToggle} from './LanguageToggle' import {ConfirmLanguagesButton} from './ConfirmLanguagesButton' +import {Trans} from '@lingui/macro' +import {useModalControls} from '#/state/modals' +import { + useLanguagePrefs, + useLanguagePrefsApi, +} from '#/state/preferences/languages' export const snapPoints = ['100%'] export function Component({}: {}) { - const store = useStores() + const {closeModal} = useModalControls() + const langPrefs = useLanguagePrefs() + const setLangPrefs = useLanguagePrefsApi() const pal = usePalette('default') const {isMobile} = useWebMediaQueries() const onPressDone = React.useCallback(() => { - store.shell.closeModal() - }, [store]) + closeModal() + }, [closeModal]) const languages = React.useMemo(() => { const langs = LANGUAGES.filter( @@ -29,23 +36,23 @@ export function Component({}: {}) { // sort so that device & selected languages are on top, then alphabetically langs.sort((a, b) => { const hasA = - store.preferences.hasContentLanguage(a.code2) || + langPrefs.contentLanguages.includes(a.code2) || deviceLocales.includes(a.code2) const hasB = - store.preferences.hasContentLanguage(b.code2) || + langPrefs.contentLanguages.includes(b.code2) || deviceLocales.includes(b.code2) if (hasA === hasB) return a.name.localeCompare(b.name) if (hasA) return -1 return 1 }) return langs - }, [store]) + }, [langPrefs]) const onPress = React.useCallback( (code2: string) => { - store.preferences.toggleContentLanguage(code2) + setLangPrefs.toggleContentLanguage(code2) }, - [store], + [setLangPrefs], ) return ( @@ -63,12 +70,16 @@ export function Component({}: {}) { maxHeight: '90vh', }, ]}> - <Text style={[pal.text, styles.title]}>Content Languages</Text> + <Text style={[pal.text, styles.title]}> + <Trans>Content Languages</Trans> + </Text> <Text style={[pal.text, styles.description]}> - Which languages would you like to see in your algorithmic feeds? + <Trans> + Which languages would you like to see in your algorithmic feeds? + </Trans> </Text> <Text style={[pal.textLight, styles.description]}> - Leave them all unchecked to see any language. + <Trans>Leave them all unchecked to see any language.</Trans> </Text> <ScrollView style={styles.scrollContainer}> {languages.map(lang => ( diff --git a/src/view/com/modals/lang-settings/LanguageToggle.tsx b/src/view/com/modals/lang-settings/LanguageToggle.tsx index 187b46e8c..45b100f20 100644 --- a/src/view/com/modals/lang-settings/LanguageToggle.tsx +++ b/src/view/com/modals/lang-settings/LanguageToggle.tsx @@ -1,11 +1,10 @@ import React from 'react' import {StyleSheet} from 'react-native' import {usePalette} from 'lib/hooks/usePalette' -import {observer} from 'mobx-react-lite' import {ToggleButton} from 'view/com/util/forms/ToggleButton' -import {useStores} from 'state/index' +import {useLanguagePrefs, toPostLanguages} from '#/state/preferences/languages' -export const LanguageToggle = observer(function LanguageToggleImpl({ +export function LanguageToggle({ code2, name, onPress, @@ -17,17 +16,17 @@ export const LanguageToggle = observer(function LanguageToggleImpl({ langType: 'contentLanguages' | 'postLanguages' }) { const pal = usePalette('default') - const store = useStores() + const langPrefs = useLanguagePrefs() - const isSelected = store.preferences[langType].includes(code2) + const values = + langType === 'contentLanguages' + ? langPrefs.contentLanguages + : toPostLanguages(langPrefs.postLanguage) + const isSelected = values.includes(code2) // enforce a max of 3 selections for post languages let isDisabled = false - if ( - langType === 'postLanguages' && - store.preferences[langType].length >= 3 && - !isSelected - ) { + if (langType === 'postLanguages' && values.length >= 3 && !isSelected) { isDisabled = true } @@ -39,7 +38,7 @@ export const LanguageToggle = observer(function LanguageToggleImpl({ style={[pal.border, styles.languageToggle, isDisabled && styles.dimmed]} /> ) -}) +} const styles = StyleSheet.create({ languageToggle: { diff --git a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx index d74d884cc..05cfb8115 100644 --- a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx +++ b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx @@ -1,8 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {ScrollView} from '../util' -import {useStores} from 'state/index' import {Text} from '../../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' @@ -10,16 +8,25 @@ import {deviceLocales} from 'platform/detection' import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages' import {ConfirmLanguagesButton} from './ConfirmLanguagesButton' import {ToggleButton} from 'view/com/util/forms/ToggleButton' +import {Trans} from '@lingui/macro' +import {useModalControls} from '#/state/modals' +import { + useLanguagePrefs, + useLanguagePrefsApi, + hasPostLanguage, +} from '#/state/preferences/languages' export const snapPoints = ['100%'] -export const Component = observer(function PostLanguagesSettingsImpl() { - const store = useStores() +export function Component() { + const {closeModal} = useModalControls() + const langPrefs = useLanguagePrefs() + const setLangPrefs = useLanguagePrefsApi() const pal = usePalette('default') const {isMobile} = useWebMediaQueries() const onPressDone = React.useCallback(() => { - store.shell.closeModal() - }, [store]) + closeModal() + }, [closeModal]) const languages = React.useMemo(() => { const langs = LANGUAGES.filter( @@ -30,23 +37,23 @@ export const Component = observer(function PostLanguagesSettingsImpl() { // sort so that device & selected languages are on top, then alphabetically langs.sort((a, b) => { const hasA = - store.preferences.hasPostLanguage(a.code2) || + hasPostLanguage(langPrefs.postLanguage, a.code2) || deviceLocales.includes(a.code2) const hasB = - store.preferences.hasPostLanguage(b.code2) || + hasPostLanguage(langPrefs.postLanguage, b.code2) || deviceLocales.includes(b.code2) if (hasA === hasB) return a.name.localeCompare(b.name) if (hasA) return -1 return 1 }) return langs - }, [store]) + }, [langPrefs]) const onPress = React.useCallback( (code2: string) => { - store.preferences.togglePostLanguage(code2) + setLangPrefs.togglePostLanguage(code2) }, - [store], + [setLangPrefs], ) return ( @@ -64,20 +71,19 @@ export const Component = observer(function PostLanguagesSettingsImpl() { maxHeight: '90vh', }, ]}> - <Text style={[pal.text, styles.title]}>Post Languages</Text> + <Text style={[pal.text, styles.title]}> + <Trans>Post Languages</Trans> + </Text> <Text style={[pal.text, styles.description]}> - Which languages are used in this post? + <Trans>Which languages are used in this post?</Trans> </Text> <ScrollView style={styles.scrollContainer}> {languages.map(lang => { - const isSelected = store.preferences.hasPostLanguage(lang.code2) + const isSelected = hasPostLanguage(langPrefs.postLanguage, lang.code2) // enforce a max of 3 selections for post languages let isDisabled = false - if ( - store.preferences.postLanguage.split(',').length >= 3 && - !isSelected - ) { + if (langPrefs.postLanguage.split(',').length >= 3 && !isSelected) { isDisabled = true } @@ -104,7 +110,7 @@ export const Component = observer(function PostLanguagesSettingsImpl() { <ConfirmLanguagesButton onPress={onPressDone} /> </View> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/modals/report/InputIssueDetails.tsx b/src/view/com/modals/report/InputIssueDetails.tsx index 70a8f7b24..2f701b799 100644 --- a/src/view/com/modals/report/InputIssueDetails.tsx +++ b/src/view/com/modals/report/InputIssueDetails.tsx @@ -8,6 +8,8 @@ import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {s} from 'lib/styles' import {SendReportButton} from './SendReportButton' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export function InputIssueDetails({ details, @@ -23,6 +25,7 @@ export function InputIssueDetails({ isProcessing: boolean }) { const pal = usePalette('default') + const {_} = useLingui() const {isMobile} = useWebMediaQueries() return ( @@ -35,14 +38,16 @@ export function InputIssueDetails({ style={[s.mb10, styles.backBtn]} onPress={goBack} accessibilityRole="button" - accessibilityLabel="Add details" + accessibilityLabel={_(msg`Add details`)} accessibilityHint="Add more details to your report"> <FontAwesomeIcon size={18} icon="angle-left" style={[pal.link]} /> - <Text style={[pal.text, s.f18, pal.link]}> Back</Text> + <Text style={[pal.text, s.f18, pal.link]}> + <Trans> Back</Trans> + </Text> </TouchableOpacity> <View style={[pal.btn, styles.detailsInputContainer]}> <TextInput - accessibilityLabel="Text input field" + accessibilityLabel={_(msg`Text input field`)} accessibilityHint="Enter a reason for reporting this post." placeholder="Enter a reason or any other details here." placeholderTextColor={pal.textLight.color} diff --git a/src/view/com/modals/report/Modal.tsx b/src/view/com/modals/report/Modal.tsx index 98aa2d471..60c3f06b7 100644 --- a/src/view/com/modals/report/Modal.tsx +++ b/src/view/com/modals/report/Modal.tsx @@ -2,7 +2,6 @@ import React, {useState, useMemo} from 'react' import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native' import {ScrollView} from 'react-native-gesture-handler' import {AtUri} from '@atproto/api' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {s} from 'lib/styles' import {Text} from '../../util/text/Text' @@ -14,6 +13,10 @@ import {SendReportButton} from './SendReportButton' import {InputIssueDetails} from './InputIssueDetails' import {ReportReasonOptions} from './ReasonOptions' import {CollectionId} from './types' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {getAgent} from '#/state/session' const DMCA_LINK = 'https://blueskyweb.xyz/support/copyright' @@ -36,7 +39,7 @@ type ReportComponentProps = } export function Component(content: ReportComponentProps) { - const store = useStores() + const {closeModal} = useModalControls() const pal = usePalette('default') const {isMobile} = useWebMediaQueries() const [isProcessing, setIsProcessing] = useState(false) @@ -60,13 +63,13 @@ export function Component(content: ReportComponentProps) { try { if (issue === '__copyright__') { Linking.openURL(DMCA_LINK) - store.shell.closeModal() + closeModal() return } const $type = !isAccountReport ? 'com.atproto.repo.strongRef' : 'com.atproto.admin.defs#repoRef' - await store.agent.createModerationReport({ + await getAgent().createModerationReport({ reasonType: issue, subject: { $type, @@ -76,7 +79,7 @@ export function Component(content: ReportComponentProps) { }) Toast.show("Thank you for your report! We'll look into it promptly.") - store.shell.closeModal() + closeModal() return } catch (e: any) { setError(cleanError(e)) @@ -146,6 +149,7 @@ const SelectIssue = ({ atUri: AtUri | null }) => { const pal = usePalette('default') + const {_} = useLingui() const collectionName = getCollectionNameForReport(atUri) const onSelectIssue = (v: string) => setIssue(v) const goToDetails = () => { @@ -158,9 +162,11 @@ const SelectIssue = ({ return ( <> - <Text style={[pal.text, styles.title]}>Report {collectionName}</Text> + <Text style={[pal.text, styles.title]}> + <Trans>Report {collectionName}</Trans> + </Text> <Text style={[pal.textLight, styles.description]}> - What is the issue with this {collectionName}? + <Trans>What is the issue with this {collectionName}?</Trans> </Text> <View style={{marginBottom: 10}}> <ReportReasonOptions @@ -182,9 +188,11 @@ const SelectIssue = ({ style={styles.addDetailsBtn} onPress={goToDetails} accessibilityRole="button" - accessibilityLabel="Add details" + accessibilityLabel={_(msg`Add details`)} accessibilityHint="Add more details to your report"> - <Text style={[s.f18, pal.link]}>Add details to report</Text> + <Text style={[s.f18, pal.link]}> + <Trans>Add details to report</Trans> + </Text> </TouchableOpacity> </> ) : undefined} diff --git a/src/view/com/modals/report/SendReportButton.tsx b/src/view/com/modals/report/SendReportButton.tsx index 82fb65f20..40c239bff 100644 --- a/src/view/com/modals/report/SendReportButton.tsx +++ b/src/view/com/modals/report/SendReportButton.tsx @@ -8,6 +8,8 @@ import { } from 'react-native' import {Text} from '../../util/text/Text' import {s, gradients, colors} from 'lib/styles' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export function SendReportButton({ onPress, @@ -16,6 +18,7 @@ export function SendReportButton({ onPress: () => void isProcessing: boolean }) { + const {_} = useLingui() // loading state // = if (isProcessing) { @@ -31,14 +34,16 @@ export function SendReportButton({ style={s.mt10} onPress={onPress} accessibilityRole="button" - accessibilityLabel="Report post" + accessibilityLabel={_(msg`Report post`)} accessibilityHint={`Reports post with reason and details`}> <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]}>Send Report</Text> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Send Report</Trans> + </Text> </LinearGradient> </TouchableOpacity> ) diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 74769bc76..260c9bbd5 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -1,66 +1,76 @@ import React, {MutableRefObject} from 'react' -import {observer} from 'mobx-react-lite' import {CenteredView, FlatList} from '../util/Views' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' -import {NotificationsFeedModel} from 'state/models/feeds/notifications' import {FeedItem} from './FeedItem' import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {EmptyState} from '../util/EmptyState' -import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' +import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' +import {useNotificationFeedQuery} from '#/state/queries/notifications/feed' +import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread' import {logger} from '#/logger' +import {cleanError} from '#/lib/strings/errors' +import {useModerationOpts} from '#/state/queries/preferences' const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -const LOADING_SPINNER = {_reactKey: '__loading_spinner__'} +const LOADING_ITEM = {_reactKey: '__loading__'} -export const Feed = observer(function Feed({ - view, +export function Feed({ scrollElRef, onPressTryAgain, onScroll, ListHeaderComponent, }: { - view: NotificationsFeedModel scrollElRef?: MutableRefObject<FlatList<any> | null> onPressTryAgain?: () => void - onScroll?: OnScrollCb + onScroll?: OnScrollHandler ListHeaderComponent?: () => JSX.Element }) { const pal = usePalette('default') const [isPTRing, setIsPTRing] = React.useState(false) - const data = React.useMemo(() => { - let feedItems: any[] = [] - if (view.isRefreshing && !isPTRing) { - feedItems = [LOADING_SPINNER] - } - if (view.hasLoaded) { - if (view.isEmpty) { - feedItems = feedItems.concat([EMPTY_FEED_ITEM]) - } else { - feedItems = feedItems.concat(view.notifications) + + const moderationOpts = useModerationOpts() + const {checkUnread} = useUnreadNotificationsApi() + const { + data, + isFetching, + isFetched, + isError, + error, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useNotificationFeedQuery({enabled: !!moderationOpts}) + const isEmpty = !isFetching && !data?.pages[0]?.items.length + + const items = React.useMemo(() => { + let arr: any[] = [] + if (isFetched) { + if (isEmpty) { + arr = arr.concat([EMPTY_FEED_ITEM]) + } else if (data) { + for (const page of data?.pages) { + arr = arr.concat(page.items) + } } + if (isError && !isEmpty) { + arr = arr.concat([LOAD_MORE_ERROR_ITEM]) + } + } else { + arr.push(LOADING_ITEM) } - if (view.loadMoreError) { - feedItems = (feedItems || []).concat([LOAD_MORE_ERROR_ITEM]) - } - return feedItems - }, [ - view.hasLoaded, - view.isEmpty, - view.notifications, - view.loadMoreError, - view.isRefreshing, - isPTRing, - ]) + return arr + }, [isFetched, isError, isEmpty, data]) const onRefresh = React.useCallback(async () => { try { setIsPTRing(true) - await view.refresh() + await checkUnread({invalidate: true}) } catch (err) { logger.error('Failed to refresh notifications feed', { error: err, @@ -68,21 +78,21 @@ export const Feed = observer(function Feed({ } finally { setIsPTRing(false) } - }, [view, setIsPTRing]) + }, [checkUnread, setIsPTRing]) const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + try { - await view.loadMore() + await fetchNextPage() } catch (err) { - logger.error('Failed to load more notifications', { - error: err, - }) + logger.error('Failed to load more notifications', {error: err}) } - }, [view]) + }, [isFetching, hasNextPage, isError, fetchNextPage]) const onPressRetryLoadMore = React.useCallback(() => { - view.retryLoadMore() - }, [view]) + fetchNextPage() + }, [fetchNextPage]) // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf // VirtualizedList: You have a large list that is slow to update - make sure your @@ -105,77 +115,66 @@ export const Feed = observer(function Feed({ onPress={onPressRetryLoadMore} /> ) - } else if (item === LOADING_SPINNER) { - return ( - <View style={styles.loading}> - <ActivityIndicator size="small" /> - </View> - ) + } else if (item === LOADING_ITEM) { + return <NotificationFeedLoadingPlaceholder /> } - return <FeedItem item={item} /> + return <FeedItem item={item} moderationOpts={moderationOpts!} /> }, - [onPressRetryLoadMore], + [onPressRetryLoadMore, moderationOpts], ) const FeedFooter = React.useCallback( () => - view.isLoading ? ( + isFetchingNextPage ? ( <View style={styles.feedFooter}> <ActivityIndicator /> </View> ) : ( <View /> ), - [view], + [isFetchingNextPage], ) + const scrollHandler = useAnimatedScrollHandler(onScroll || {}) return ( <View style={s.hContentRegion}> - <CenteredView> - {view.isLoading && !data.length && ( - <NotificationFeedLoadingPlaceholder /> - )} - {view.hasError && ( + {error && ( + <CenteredView> <ErrorMessage - message={view.error} + message={cleanError(error)} onPressTryAgain={onPressTryAgain} /> - )} - </CenteredView> - {data.length ? ( - <FlatList - testID="notifsFeed" - ref={scrollElRef} - data={data} - keyExtractor={item => item._reactKey} - renderItem={renderItem} - ListHeaderComponent={ListHeaderComponent} - ListFooterComponent={FeedFooter} - refreshControl={ - <RefreshControl - refreshing={isPTRing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - onEndReached={onEndReached} - onEndReachedThreshold={0.6} - onScroll={onScroll} - scrollEventThrottle={100} - contentContainerStyle={s.contentContainer} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> - ) : null} + </CenteredView> + )} + <FlatList + testID="notifsFeed" + ref={scrollElRef} + data={items} + keyExtractor={item => item._reactKey} + renderItem={renderItem} + ListHeaderComponent={ListHeaderComponent} + ListFooterComponent={FeedFooter} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onEndReached={onEndReached} + onEndReachedThreshold={0.6} + onScroll={scrollHandler} + scrollEventThrottle={1} + contentContainerStyle={s.contentContainer} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> </View> ) -}) +} const styles = StyleSheet.create({ - loading: { - paddingVertical: 20, - }, feedFooter: {paddingTop: 20}, emptyState: {paddingVertical: 40}, }) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index c38ab3fd5..aaa2ea2c6 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -1,5 +1,4 @@ -import React, {useMemo, useState, useEffect} from 'react' -import {observer} from 'mobx-react-lite' +import React, {memo, useMemo, useState, useEffect} from 'react' import { Animated, TouchableOpacity, @@ -9,6 +8,9 @@ import { } from 'react-native' import { AppBskyEmbedImages, + AppBskyFeedDefs, + AppBskyFeedPost, + ModerationOpts, ProfileModeration, moderateProfile, AppBskyEmbedRecordWithMedia, @@ -19,8 +21,7 @@ import { FontAwesomeIconStyle, Props, } from '@fortawesome/react-native-fontawesome' -import {NotificationsFeedItemModel} from 'state/models/feeds/notifications' -import {PostThreadModel} from 'state/models/content/post-thread' +import {FeedNotification} from '#/state/queries/notifications/feed' import {s, colors} from 'lib/styles' import {niceDate} from 'lib/strings/time' import {sanitizeDisplayName} from 'lib/strings/display-names' @@ -33,13 +34,14 @@ import {UserPreviewLink} from '../util/UserPreviewLink' import {ImageHorzList} from '../util/images/ImageHorzList' import {Post} from '../post/Post' import {Link, TextLink} from '../util/Link' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {formatCount} from '../util/numeric/format' import {makeProfileLink} from 'lib/routes/links' import {TimeElapsed} from '../util/TimeElapsed' import {isWeb} from 'platform/detection' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' const MAX_AUTHORS = 5 @@ -54,40 +56,34 @@ interface Author { moderation: ProfileModeration } -export const FeedItem = observer(function FeedItemImpl({ +let FeedItem = ({ item, + moderationOpts, }: { - item: NotificationsFeedItemModel -}) { - const store = useStores() + item: FeedNotification + moderationOpts: ModerationOpts +}): React.ReactNode => { const pal = usePalette('default') const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false) const itemHref = useMemo(() => { - if (item.isLike || item.isRepost) { - const urip = new AtUri(item.subjectUri) - return `/profile/${urip.host}/post/${urip.rkey}` - } else if (item.isFollow) { - return makeProfileLink(item.author) - } else if (item.isReply) { - const urip = new AtUri(item.uri) + if (item.type === 'post-like' || item.type === 'repost') { + if (item.subjectUri) { + const urip = new AtUri(item.subjectUri) + return `/profile/${urip.host}/post/${urip.rkey}` + } + } else if (item.type === 'follow') { + return makeProfileLink(item.notification.author) + } else if (item.type === 'reply') { + const urip = new AtUri(item.notification.uri) return `/profile/${urip.host}/post/${urip.rkey}` - } else if (item.isCustomFeedLike) { - const urip = new AtUri(item.subjectUri) - return `/profile/${urip.host}/feed/${urip.rkey}` + } else if (item.type === 'feedgen-like') { + if (item.subjectUri) { + const urip = new AtUri(item.subjectUri) + return `/profile/${urip.host}/feed/${urip.rkey}` + } } return '' }, [item]) - const itemTitle = useMemo(() => { - if (item.isLike || item.isRepost) { - return 'Post' - } else if (item.isFollow) { - return item.author.handle - } else if (item.isReply) { - return 'Post' - } else if (item.isCustomFeedLike) { - return 'Custom Feed' - } - }, [item]) const onToggleAuthorsExpanded = () => { setAuthorsExpanded(currentlyExpanded => !currentlyExpanded) @@ -96,15 +92,12 @@ export const FeedItem = observer(function FeedItemImpl({ const authors: Author[] = useMemo(() => { return [ { - href: makeProfileLink(item.author), - did: item.author.did, - handle: item.author.handle, - displayName: item.author.displayName, - avatar: item.author.avatar, - moderation: moderateProfile( - item.author, - store.preferences.moderationOpts, - ), + href: makeProfileLink(item.notification.author), + did: item.notification.author.did, + handle: item.notification.author.handle, + displayName: item.notification.author.displayName, + avatar: item.notification.author.avatar, + moderation: moderateProfile(item.notification.author, moderationOpts), }, ...(item.additional?.map(({author}) => { return { @@ -113,33 +106,35 @@ export const FeedItem = observer(function FeedItemImpl({ handle: author.handle, displayName: author.displayName, avatar: author.avatar, - moderation: moderateProfile(author, store.preferences.moderationOpts), + moderation: moderateProfile(author, moderationOpts), } }) || []), ] - }, [store, item.additional, item.author]) + }, [item, moderationOpts]) - if (item.additionalPost?.notFound) { + if (item.subjectUri && !item.subject) { // don't render anything if the target post was deleted or unfindable return <View /> } - if (item.isReply || item.isMention || item.isQuote) { - if (!item.additionalPost || item.additionalPost?.error) { - // hide errors - it doesnt help the user to show them - return <View /> + if ( + item.type === 'reply' || + item.type === 'mention' || + item.type === 'quote' + ) { + if (!item.subject) { + return null } return ( <Link - testID={`feedItem-by-${item.author.handle}`} + testID={`feedItem-by-${item.notification.author.handle}`} href={itemHref} - title={itemTitle} noFeedback accessible={false}> <Post - view={item.additionalPost} + post={item.subject} style={ - item.isRead + item.notification.isRead ? undefined : { backgroundColor: pal.colors.unreadNotifBg, @@ -154,23 +149,25 @@ export const FeedItem = observer(function FeedItemImpl({ let action = '' let icon: Props['icon'] | 'HeartIconSolid' let iconStyle: Props['style'] = [] - if (item.isLike) { + if (item.type === 'post-like') { action = 'liked your post' icon = 'HeartIconSolid' iconStyle = [ s.likeColor as FontAwesomeIconStyle, {position: 'relative', top: -4}, ] - } else if (item.isRepost) { + } else if (item.type === 'repost') { action = 'reposted your post' icon = 'retweet' iconStyle = [s.green3 as FontAwesomeIconStyle] - } else if (item.isFollow) { + } else if (item.type === 'follow') { action = 'followed you' icon = 'user-plus' iconStyle = [s.blue3 as FontAwesomeIconStyle] - } else if (item.isCustomFeedLike) { - action = `liked your custom feed '${new AtUri(item.subjectUri).rkey}'` + } else if (item.type === 'feedgen-like') { + action = `liked your custom feed${ + item.subjectUri ? ` '${new AtUri(item.subjectUri).rkey}}'` : '' + }` icon = 'HeartIconSolid' iconStyle = [ s.likeColor as FontAwesomeIconStyle, @@ -182,12 +179,12 @@ export const FeedItem = observer(function FeedItemImpl({ return ( <Link - testID={`feedItem-by-${item.author.handle}`} + testID={`feedItem-by-${item.notification.author.handle}`} style={[ styles.outer, pal.view, pal.border, - item.isRead + item.notification.isRead ? undefined : { backgroundColor: pal.colors.unreadNotifBg, @@ -195,9 +192,11 @@ export const FeedItem = observer(function FeedItemImpl({ }, ]} href={itemHref} - title={itemTitle} noFeedback - accessible={(item.isLike && authors.length === 1) || item.isRepost}> + accessible={ + (item.type === 'post-like' && authors.length === 1) || + item.type === 'repost' + }> <View style={styles.layoutIcon}> {/* TODO: Prevent conditional rendering and move toward composable notifications for clearer accessibility labeling */} @@ -232,7 +231,10 @@ export const FeedItem = observer(function FeedItemImpl({ /> {authors.length > 1 ? ( <> - <Text style={[pal.text]}> and </Text> + <Text style={[pal.text, s.mr5, s.ml5]}> + {' '} + <Trans>and</Trans>{' '} + </Text> <Text style={[pal.text, s.bold]}> {formatCount(authors.length - 1)}{' '} {pluralize(authors.length - 1, 'other')} @@ -240,24 +242,26 @@ export const FeedItem = observer(function FeedItemImpl({ </> ) : undefined} <Text style={[pal.text]}> {action}</Text> - <TimeElapsed timestamp={item.indexedAt}> + <TimeElapsed timestamp={item.notification.indexedAt}> {({timeElapsed}) => ( <Text style={[pal.textLight, styles.pointer]} - title={niceDate(item.indexedAt)}> + title={niceDate(item.notification.indexedAt)}> {' ' + timeElapsed} </Text> )} </TimeElapsed> </Text> </ExpandListPressable> - {item.isLike || item.isRepost || item.isQuote ? ( - <AdditionalPostText additionalPost={item.additionalPost} /> + {item.type === 'post-like' || item.type === 'repost' ? ( + <AdditionalPostText post={item.subject} /> ) : null} </View> </Link> ) -}) +} +FeedItem = memo(FeedItem) +export {FeedItem} function ExpandListPressable({ hasMultipleAuthors, @@ -292,6 +296,8 @@ function CondensedAuthorsList({ onToggleAuthorsExpanded: () => void }) { const pal = usePalette('default') + const {_} = useLingui() + if (!visible) { return ( <View style={styles.avis}> @@ -299,7 +305,7 @@ function CondensedAuthorsList({ style={styles.expandedAuthorsCloseBtn} onPress={onToggleAuthorsExpanded} accessibilityRole="button" - accessibilityLabel="Hide user list" + accessibilityLabel={_(msg`Hide user list`)} accessibilityHint="Collapses list of users for a given notification"> <FontAwesomeIcon icon="angle-up" @@ -307,7 +313,7 @@ function CondensedAuthorsList({ style={[styles.expandedAuthorsCloseBtnIcon, pal.text]} /> <Text type="sm-medium" style={pal.text}> - Hide + <Trans>Hide</Trans> </Text> </TouchableOpacity> </View> @@ -328,7 +334,7 @@ function CondensedAuthorsList({ } return ( <TouchableOpacity - accessibilityLabel="Show users" + accessibilityLabel={_(msg`Show users`)} accessibilityHint="Opens an expanded list of users in this notification" onPress={onToggleAuthorsExpanded}> <View style={styles.avis}> @@ -417,34 +423,25 @@ function ExpandedAuthorsList({ ) } -function AdditionalPostText({ - additionalPost, -}: { - additionalPost?: PostThreadModel -}) { +function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { const pal = usePalette('default') - if ( - !additionalPost || - !additionalPost.thread?.postRecord || - additionalPost.error - ) { - return <View /> + if (post && AppBskyFeedPost.isRecord(post?.record)) { + const text = post.record.text + const images = AppBskyEmbedImages.isView(post.embed) + ? post.embed.images + : AppBskyEmbedRecordWithMedia.isView(post.embed) && + AppBskyEmbedImages.isView(post.embed.media) + ? post.embed.media.images + : undefined + return ( + <> + {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>} + {images && images?.length > 0 && ( + <ImageHorzList images={images} style={styles.additionalPostImages} /> + )} + </> + ) } - const text = additionalPost.thread?.postRecord.text - const images = AppBskyEmbedImages.isView(additionalPost.thread.post.embed) - ? additionalPost.thread.post.embed.images - : AppBskyEmbedRecordWithMedia.isView(additionalPost.thread.post.embed) && - AppBskyEmbedImages.isView(additionalPost.thread.post.embed.media) - ? additionalPost.thread.post.embed.media.images - : undefined - return ( - <> - {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>} - {images && images?.length > 0 && ( - <ImageHorzList images={images} style={styles.additionalPostImages} /> - )} - </> - ) } const styles = StyleSheet.create({ diff --git a/src/view/com/notifications/InvitedUsers.tsx b/src/view/com/notifications/InvitedUsers.tsx deleted file mode 100644 index aaf358b87..000000000 --- a/src/view/com/notifications/InvitedUsers.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React from 'react' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {AppBskyActorDefs} from '@atproto/api' -import {UserAvatar} from '../util/UserAvatar' -import {Text} from '../util/text/Text' -import {Link, TextLink} from '../util/Link' -import {Button} from '../util/forms/Button' -import {FollowButton} from '../profile/FollowButton' -import {CenteredView} from '../util/Views.web' -import {useStores} from 'state/index' -import {usePalette} from 'lib/hooks/usePalette' -import {s} from 'lib/styles' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {makeProfileLink} from 'lib/routes/links' - -export const InvitedUsers = observer(function InvitedUsersImpl() { - const store = useStores() - return ( - <CenteredView> - {store.invitedUsers.profiles.map(profile => ( - <InvitedUser key={profile.did} profile={profile} /> - ))} - </CenteredView> - ) -}) - -function InvitedUser({ - profile, -}: { - profile: AppBskyActorDefs.ProfileViewDetailed -}) { - const pal = usePalette('default') - const store = useStores() - - const onPressDismiss = React.useCallback(() => { - store.invitedUsers.markSeen(profile.did) - }, [store, profile]) - - return ( - <View - testID="invitedUser" - style={[ - styles.layout, - { - backgroundColor: pal.colors.unreadNotifBg, - borderColor: pal.colors.unreadNotifBorder, - }, - ]}> - <View style={styles.layoutIcon}> - <FontAwesomeIcon - icon="user-plus" - size={24} - style={[styles.icon, s.blue3 as FontAwesomeIconStyle]} - /> - </View> - <View style={s.flex1}> - <Link href={makeProfileLink(profile)}> - <UserAvatar avatar={profile.avatar} size={35} /> - </Link> - <Text style={[styles.desc, pal.text]}> - <TextLink - type="md-bold" - style={pal.text} - href={makeProfileLink(profile)} - text={sanitizeDisplayName(profile.displayName || profile.handle)} - />{' '} - joined using your invite code! - </Text> - <View style={styles.btns}> - <FollowButton - unfollowedType="primary" - followedType="primary-light" - profile={profile} - /> - <Button - testID="dismissBtn" - type="primary-light" - label="Dismiss" - onPress={onPressDismiss} - /> - </View> - </View> - </View> - ) -} - -const styles = StyleSheet.create({ - layout: { - flexDirection: 'row', - borderTopWidth: 1, - padding: 10, - }, - layoutIcon: { - width: 70, - alignItems: 'flex-end', - paddingTop: 2, - }, - icon: { - marginRight: 10, - marginTop: 4, - }, - desc: { - paddingVertical: 6, - }, - btns: { - flexDirection: 'row', - gap: 10, - }, -}) diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index 25755bafe..57c83f17c 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -1,50 +1,136 @@ import React from 'react' -import {StyleSheet} from 'react-native' +import {View, StyleSheet} from 'react-native' import Animated from 'react-native-reanimated' -import {observer} from 'mobx-react-lite' import {TabBar} from 'view/com/pager/TabBar' import {RenderTabBarFnProps} from 'view/com/pager/Pager' -import {useStores} from 'state/index' -import {useHomeTabs} from 'lib/hooks/useHomeTabs' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import {useShellLayout} from '#/state/shell/shell-layout' +import {usePinnedFeedsInfos} from '#/state/queries/feed' +import {useSession} from '#/state/session' +import {TextLink} from '#/view/com/util/Link' +import {CenteredView} from '../util/Views' +import {isWeb} from 'platform/detection' +import {useNavigation} from '@react-navigation/native' +import {NavigationProp} from 'lib/routes/types' -export const FeedsTabBar = observer(function FeedsTabBarImpl( +export function FeedsTabBar( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { const {isMobile, isTablet} = useWebMediaQueries() + const {hasSession} = useSession() + if (isMobile) { return <FeedsTabBarMobile {...props} /> } else if (isTablet) { - return <FeedsTabBarTablet {...props} /> + if (hasSession) { + return <FeedsTabBarTablet {...props} /> + } else { + return <FeedsTabBarPublic /> + } } else { return null } -}) +} + +function FeedsTabBarPublic() { + const pal = usePalette('default') + const {isSandbox} = useSession() -const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl( + return ( + <CenteredView sideBorders> + <View + style={[ + pal.view, + { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 18, + paddingVertical: 12, + }, + ]}> + <TextLink + type="title-lg" + href="/" + style={[pal.text, {fontWeight: 'bold'}]} + text={ + <> + {isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} + {/*hasNew && ( + <View + style={{ + top: -8, + backgroundColor: colors.blue3, + width: 8, + height: 8, + borderRadius: 4, + }} + /> + )*/} + </> + } + // onPress={emitSoftReset} + /> + </View> + </CenteredView> + ) +} + +function FeedsTabBarTablet( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { - const store = useStores() - const items = useHomeTabs(store.preferences.pinnedFeeds) + const {feeds, hasPinnedCustom} = usePinnedFeedsInfos() const pal = usePalette('default') + const {hasSession} = useSession() + const navigation = useNavigation<NavigationProp>() const {headerMinimalShellTransform} = useMinimalShellMode() + const {headerHeight} = useShellLayout() + const pinnedDisplayNames = hasSession ? feeds.map(f => f.displayName) : [] + const showFeedsLinkInTabBar = hasSession && !hasPinnedCustom + const items = showFeedsLinkInTabBar + ? pinnedDisplayNames.concat('Feeds ✨') + : pinnedDisplayNames + + const onPressDiscoverFeeds = React.useCallback(() => { + if (isWeb) { + navigation.navigate('Feeds') + } else { + navigation.navigate('FeedsTab') + navigation.popToTop() + } + }, [navigation]) + + const onSelect = React.useCallback( + (index: number) => { + if (showFeedsLinkInTabBar && index === items.length - 1) { + onPressDiscoverFeeds() + } else if (props.onSelect) { + props.onSelect(index) + } + }, + [items.length, onPressDiscoverFeeds, props, showFeedsLinkInTabBar], + ) 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, styles.tabBar, headerMinimalShellTransform]} + onLayout={e => { + headerHeight.value = e.nativeEvent.layout.height + }}> <TabBar key={items.join(',')} {...props} + onSelect={onSelect} items={items} indicatorColor={pal.colors.link} /> </Animated.View> ) -}) +} const styles = StyleSheet.create({ tabBar: { diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index 9848ce2d5..882b6cfc5 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -1,10 +1,7 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {TabBar} from 'view/com/pager/TabBar' import {RenderTabBarFnProps} from 'view/com/pager/Pager' -import {useStores} from 'state/index' -import {useHomeTabs} from 'lib/hooks/useHomeTabs' import {usePalette} from 'lib/hooks/usePalette' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {Link} from '../util/Link' @@ -14,18 +11,54 @@ import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {s} from 'lib/styles' import {HITSLOP_10} from 'lib/constants' import Animated from 'react-native-reanimated' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {useSetDrawerOpen} from '#/state/shell/drawer-open' +import {useShellLayout} from '#/state/shell/shell-layout' +import {useSession} from '#/state/session' +import {usePinnedFeedsInfos} from '#/state/queries/feed' +import {isWeb} from 'platform/detection' +import {useNavigation} from '@react-navigation/native' +import {NavigationProp} from 'lib/routes/types' -export const FeedsTabBar = observer(function FeedsTabBarImpl( +export function FeedsTabBar( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { const pal = usePalette('default') - const store = useStores() + const {isSandbox, hasSession} = useSession() + const {_} = useLingui() const setDrawerOpen = useSetDrawerOpen() - const items = useHomeTabs(store.preferences.pinnedFeeds) + const navigation = useNavigation<NavigationProp>() + const {feeds, hasPinnedCustom} = usePinnedFeedsInfos() const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) - const {minimalShellMode, headerMinimalShellTransform} = useMinimalShellMode() + const {headerHeight} = useShellLayout() + const {headerMinimalShellTransform} = useMinimalShellMode() + const pinnedDisplayNames = hasSession ? feeds.map(f => f.displayName) : [] + const showFeedsLinkInTabBar = hasSession && !hasPinnedCustom + const items = showFeedsLinkInTabBar + ? pinnedDisplayNames.concat('Feeds ✨') + : pinnedDisplayNames + + const onPressFeedsLink = React.useCallback(() => { + if (isWeb) { + navigation.navigate('Feeds') + } else { + navigation.navigate('FeedsTab') + navigation.popToTop() + } + }, [navigation]) + + const onSelect = React.useCallback( + (index: number) => { + if (showFeedsLinkInTabBar && index === items.length - 1) { + onPressFeedsLink() + } else if (props.onSelect) { + props.onSelect(index) + } + }, + [items.length, onPressFeedsLink, props, showFeedsLinkInTabBar], + ) const onPressAvi = React.useCallback(() => { setDrawerOpen(true) @@ -33,20 +66,17 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( return ( <Animated.View - style={[ - pal.view, - pal.border, - styles.tabBar, - headerMinimalShellTransform, - minimalShellMode && styles.disabled, - ]}> + style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]} + onLayout={e => { + headerHeight.value = e.nativeEvent.layout.height + }}> <View style={[pal.view, styles.topBar]}> <View style={[pal.view]}> <TouchableOpacity testID="viewHeaderDrawerBtn" onPress={onPressAvi} accessibilityRole="button" - accessibilityLabel="Open navigation" + accessibilityLabel={_(msg`Open navigation`)} accessibilityHint="Access profile and other navigation links" hitSlop={HITSLOP_10}> <FontAwesomeIcon @@ -57,35 +87,40 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( </TouchableOpacity> </View> <Text style={[brandBlue, s.bold, styles.title]}> - {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'} + {isSandbox ? 'SANDBOX' : 'Bluesky'} </Text> - <View style={[pal.view]}> - <Link - testID="viewHeaderHomeFeedPrefsBtn" - href="/settings/home-feed" - hitSlop={HITSLOP_10} - accessibilityRole="button" - accessibilityLabel="Home Feed Preferences" - accessibilityHint=""> - <FontAwesomeIcon - icon="sliders" - style={pal.textLight as FontAwesomeIconStyle} - /> - </Link> + <View style={[pal.view, {width: 18}]}> + {hasSession && ( + <Link + testID="viewHeaderHomeFeedPrefsBtn" + href="/settings/home-feed" + hitSlop={HITSLOP_10} + accessibilityRole="button" + accessibilityLabel={_(msg`Home Feed Preferences`)} + accessibilityHint=""> + <FontAwesomeIcon + icon="sliders" + style={pal.textLight as FontAwesomeIconStyle} + /> + </Link> + )} </View> </View> - <TabBar - key={items.join(',')} - onPressSelected={props.onPressSelected} - selectedPage={props.selectedPage} - onSelect={props.onSelect} - testID={props.testID} - items={items} - indicatorColor={pal.colors.link} - /> + + {items.length > 0 && ( + <TabBar + key={items.join(',')} + onPressSelected={props.onPressSelected} + selectedPage={props.selectedPage} + onSelect={onSelect} + testID={props.testID} + items={items} + indicatorColor={pal.colors.link} + /> + )} </Animated.View> ) -}) +} const styles = StyleSheet.create({ tabBar: { @@ -95,7 +130,6 @@ const styles = StyleSheet.create({ right: 0, top: 0, flexDirection: 'column', - alignItems: 'center', borderBottomWidth: 1, }, topBar: { @@ -103,14 +137,10 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 18, - paddingTop: 8, - paddingBottom: 2, + paddingVertical: 8, width: '100%', }, title: { fontSize: 21, }, - disabled: { - pointerEvents: 'none', - }, }) diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index 531a41ee2..d70087504 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -26,6 +26,9 @@ interface Props { renderTabBar: RenderTabBarFn onPageSelected?: (index: number) => void onPageSelecting?: (index: number) => void + onPageScrollStateChanged?: ( + scrollState: 'idle' | 'dragging' | 'settling', + ) => void testID?: string } export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( @@ -35,6 +38,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( tabBarPosition = 'top', initialPage = 0, renderTabBar, + onPageScrollStateChanged, onPageSelected, onPageSelecting, testID, @@ -97,11 +101,12 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( [lastOffset, lastDirection, onPageSelecting], ) - const onPageScrollStateChanged = React.useCallback( + const handlePageScrollStateChanged = React.useCallback( (e: PageScrollStateChangedNativeEvent) => { scrollState.current = e.nativeEvent.pageScrollState + onPageScrollStateChanged?.(e.nativeEvent.pageScrollState) }, - [scrollState], + [scrollState, onPageScrollStateChanged], ) const onTabBarSelect = React.useCallback( @@ -123,7 +128,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( ref={pagerView} style={s.flex1} initialPage={initialPage} - onPageScrollStateChanged={onPageScrollStateChanged} + onPageScrollStateChanged={handlePageScrollStateChanged} onPageSelected={onPageSelectedInner} onPageScroll={onPageScroll}> {children} diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx index 7ec292667..3b5e9164a 100644 --- a/src/view/com/pager/Pager.web.tsx +++ b/src/view/com/pager/Pager.web.tsx @@ -49,7 +49,18 @@ export const Pager = React.forwardRef(function PagerImpl( onSelect: onTabBarSelect, })} {React.Children.map(children, (child, i) => ( - <View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}> + <View + style={ + selectedPage === i + ? s.flex1 + : { + position: 'absolute', + pointerEvents: 'none', + // @ts-ignore web-only + visibility: 'hidden', + } + } + key={`page-${i}`}> {child} </View> ))} diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 701b52871..2d3b0cece 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -1,28 +1,36 @@ import * as React from 'react' import { LayoutChangeEvent, - NativeScrollEvent, + FlatList, + ScrollView, StyleSheet, View, + NativeScrollEvent, } from 'react-native' import Animated, { - Easing, - useAnimatedReaction, useAnimatedStyle, useSharedValue, - withTiming, runOnJS, + runOnUI, + scrollTo, + useAnimatedRef, + AnimatedRef, + SharedValue, } from 'react-native-reanimated' import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {TabBar} from './TabBar' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' const SCROLLED_DOWN_LIMIT = 200 -interface PagerWithHeaderChildParams { +export interface PagerWithHeaderChildParams { headerHeight: number - onScroll: (e: NativeScrollEvent) => void + isFocused: boolean + onScroll: OnScrollHandler isScrolledDown: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | ScrollView | null> } export interface PagerWithHeaderProps { @@ -51,117 +59,120 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( }: PagerWithHeaderProps, ref, ) { - const {isMobile} = useWebMediaQueries() const [currentPage, setCurrentPage] = React.useState(0) - const scrollYs = React.useRef<Record<number, number>>({}) - const scrollY = useSharedValue(scrollYs.current[currentPage] || 0) const [tabBarHeight, setTabBarHeight] = React.useState(0) const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0) - const [isScrolledDown, setIsScrolledDown] = React.useState( - scrollYs.current[currentPage] > SCROLLED_DOWN_LIMIT, - ) - + const [isScrolledDown, setIsScrolledDown] = React.useState(false) + const scrollY = useSharedValue(0) const headerHeight = headerOnlyHeight + tabBarHeight - // react to scroll updates - function onScrollUpdate(v: number) { - // track each page's current scroll position - scrollYs.current[currentPage] = Math.min(v, headerOnlyHeight) - // update the 'is scrolled down' value - setIsScrolledDown(v > SCROLLED_DOWN_LIMIT) - } - useAnimatedReaction( - () => scrollY.value, - v => runOnJS(onScrollUpdate)(v), - ) - // capture the header bar sizing const onTabBarLayout = React.useCallback( (evt: LayoutChangeEvent) => { - setTabBarHeight(evt.nativeEvent.layout.height) + const height = evt.nativeEvent.layout.height + if (height > 0) { + setTabBarHeight(height) + } }, [setTabBarHeight], ) const onHeaderOnlyLayout = React.useCallback( (evt: LayoutChangeEvent) => { - setHeaderOnlyHeight(evt.nativeEvent.layout.height) + const height = evt.nativeEvent.layout.height + if (height > 0) { + setHeaderOnlyHeight(height) + } }, [setHeaderOnlyHeight], ) - // render the the header and tab bar - const headerTransform = useAnimatedStyle( - () => ({ - transform: [ - { - translateY: Math.min( - Math.min(scrollY.value, headerOnlyHeight) * -1, - 0, - ), - }, - ], - }), - [scrollY, headerHeight, tabBarHeight], - ) const renderTabBar = React.useCallback( (props: RenderTabBarFnProps) => { return ( - <Animated.View - style={[ - isMobile ? styles.tabBarMobile : styles.tabBarDesktop, - headerTransform, - ]}> - <View onLayout={onHeaderOnlyLayout}>{renderHeader?.()}</View> - <View - onLayout={onTabBarLayout} - style={{ - // Render it immediately to measure it early since its size doesn't depend on the content. - // However, keep it invisible until the header above stabilizes in order to prevent jumps. - opacity: isHeaderReady ? 1 : 0, - pointerEvents: isHeaderReady ? 'auto' : 'none', - }}> - <TabBar - items={items} - selectedPage={currentPage} - onSelect={props.onSelect} - onPressSelected={onCurrentPageSelected} - /> - </View> - </Animated.View> + <PagerTabBar + headerOnlyHeight={headerOnlyHeight} + items={items} + isHeaderReady={isHeaderReady} + renderHeader={renderHeader} + currentPage={currentPage} + onCurrentPageSelected={onCurrentPageSelected} + onTabBarLayout={onTabBarLayout} + onHeaderOnlyLayout={onHeaderOnlyLayout} + onSelect={props.onSelect} + scrollY={scrollY} + testID={testID} + /> ) }, [ + headerOnlyHeight, items, isHeaderReady, renderHeader, - headerTransform, currentPage, onCurrentPageSelected, - isMobile, onTabBarLayout, onHeaderOnlyLayout, + scrollY, + testID, ], ) - // Ideally we'd call useAnimatedScrollHandler here but we can't safely do that - // due to https://github.com/software-mansion/react-native-reanimated/issues/5345. - // So instead we pass down a worklet, and individual pages will have to call it. - const onScroll = React.useCallback( - (e: NativeScrollEvent) => { + const scrollRefs = useSharedValue<AnimatedRef<any>[]>([]) + const registerRef = (scrollRef: AnimatedRef<any>, index: number) => { + scrollRefs.modify(refs => { 'worklet' - scrollY.value = e.contentOffset.y - }, - [scrollY], + refs[index] = scrollRef + return refs + }) + } + + const lastForcedScrollY = useSharedValue(0) + const adjustScrollForOtherPages = () => { + 'worklet' + const currentScrollY = scrollY.value + const forcedScrollY = Math.min(currentScrollY, headerOnlyHeight) + if (lastForcedScrollY.value !== forcedScrollY) { + lastForcedScrollY.value = forcedScrollY + const refs = scrollRefs.value + for (let i = 0; i < refs.length; i++) { + if (i !== currentPage) { + // This needs to run on the UI thread. + scrollTo(refs[i], 0, forcedScrollY, false) + } + } + } + } + + const throttleTimeout = React.useRef<ReturnType<typeof setTimeout> | null>( + null, ) + const queueThrottledOnScroll = useNonReactiveCallback(() => { + if (!throttleTimeout.current) { + throttleTimeout.current = setTimeout(() => { + throttleTimeout.current = null - // props to pass into children render functions - const childProps = React.useMemo<PagerWithHeaderChildParams>(() => { - return { - headerHeight, - onScroll, - isScrolledDown, + runOnUI(adjustScrollForOtherPages)() + + const nextIsScrolledDown = scrollY.value > SCROLLED_DOWN_LIMIT + if (isScrolledDown !== nextIsScrolledDown) { + React.startTransition(() => { + setIsScrolledDown(nextIsScrolledDown) + }) + } + }, 80 /* Sync often enough you're unlikely to catch it unsynced */) } - }, [headerHeight, onScroll, isScrolledDown]) + }) + + const onScrollWorklet = React.useCallback( + (e: NativeScrollEvent) => { + 'worklet' + const nextScrollY = e.contentOffset.y + scrollY.value = nextScrollY + runOnJS(queueThrottledOnScroll)() + }, + [scrollY, queueThrottledOnScroll], + ) const onPageSelectedInner = React.useCallback( (index: number) => { @@ -171,19 +182,9 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( [onPageSelected, setCurrentPage], ) - const onPageSelecting = React.useCallback( - (index: number) => { - setCurrentPage(index) - if (scrollY.value > headerHeight) { - scrollY.value = headerHeight - } - scrollY.value = withTiming(scrollYs.current[index] || 0, { - duration: 170, - easing: Easing.inOut(Easing.quad), - }) - }, - [scrollY, setCurrentPage, scrollYs, headerHeight], - ) + const onPageSelecting = React.useCallback((index: number) => { + setCurrentPage(index) + }, []) return ( <Pager @@ -197,20 +198,19 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( {toArray(children) .filter(Boolean) .map((child, i) => { - let output = null - if ( - child != null && - // Defer showing content until we know it won't jump. - isHeaderReady && - headerOnlyHeight > 0 && - tabBarHeight > 0 - ) { - output = child(childProps) - } - // Pager children must be noncollapsible plain <View>s. + const isReady = + isHeaderReady && headerOnlyHeight > 0 && tabBarHeight > 0 return ( <View key={i} collapsable={false}> - {output} + <PagerItem + headerHeight={headerHeight} + isReady={isReady} + isFocused={i === currentPage} + isScrolledDown={isScrolledDown} + onScrollWorklet={i === currentPage ? onScrollWorklet : noop} + registerRef={(r: AnimatedRef<any>) => registerRef(r, i)} + renderTab={child} + /> </View> ) })} @@ -219,6 +219,107 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( }, ) +let PagerTabBar = ({ + currentPage, + headerOnlyHeight, + isHeaderReady, + items, + scrollY, + testID, + renderHeader, + onHeaderOnlyLayout, + onTabBarLayout, + onCurrentPageSelected, + onSelect, +}: { + currentPage: number + headerOnlyHeight: number + isHeaderReady: boolean + items: string[] + testID?: string + scrollY: SharedValue<number> + renderHeader?: () => JSX.Element + onHeaderOnlyLayout: (e: LayoutChangeEvent) => void + onTabBarLayout: (e: LayoutChangeEvent) => void + onCurrentPageSelected?: (index: number) => void + onSelect?: (index: number) => void +}): React.ReactNode => { + const {isMobile} = useWebMediaQueries() + const headerTransform = useAnimatedStyle(() => ({ + transform: [ + { + translateY: Math.min(Math.min(scrollY.value, headerOnlyHeight) * -1, 0), + }, + ], + })) + return ( + <Animated.View + style={[ + isMobile ? styles.tabBarMobile : styles.tabBarDesktop, + headerTransform, + ]}> + <View onLayout={onHeaderOnlyLayout}>{renderHeader?.()}</View> + <View + onLayout={onTabBarLayout} + style={{ + // Render it immediately to measure it early since its size doesn't depend on the content. + // However, keep it invisible until the header above stabilizes in order to prevent jumps. + opacity: isHeaderReady ? 1 : 0, + pointerEvents: isHeaderReady ? 'auto' : 'none', + }}> + <TabBar + testID={testID} + items={items} + selectedPage={currentPage} + onSelect={onSelect} + onPressSelected={onCurrentPageSelected} + /> + </View> + </Animated.View> + ) +} +PagerTabBar = React.memo(PagerTabBar) + +function PagerItem({ + headerHeight, + isReady, + isFocused, + isScrolledDown, + onScrollWorklet, + renderTab, + registerRef, +}: { + headerHeight: number + isFocused: boolean + isReady: boolean + isScrolledDown: boolean + registerRef: (scrollRef: AnimatedRef<any>) => void + onScrollWorklet: (e: NativeScrollEvent) => void + renderTab: ((props: PagerWithHeaderChildParams) => JSX.Element) | null +}) { + const scrollElRef = useAnimatedRef() + registerRef(scrollElRef) + + const scrollHandler = React.useMemo( + () => ({onScroll: onScrollWorklet}), + [onScrollWorklet], + ) + + if (!isReady || renderTab == null) { + return null + } + + return renderTab({ + headerHeight, + isFocused, + isScrolledDown, + onScroll: scrollHandler, + scrollElRef: scrollElRef as React.MutableRefObject< + FlatList<any> | ScrollView | null + >, + }) +} + const styles = StyleSheet.create({ tabBarMobile: { position: 'absolute', @@ -237,6 +338,10 @@ const styles = StyleSheet.create({ }, }) +function noop() { + 'worklet' +} + function toArray<T>(v: T | T[]): T[] { if (Array.isArray(v)) { return v diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 0e08b22d8..c3a95c5c0 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -68,6 +68,7 @@ export function TabBar({ return ( <View testID={testID} style={[pal.view, styles.outer]}> <DraggableScrollView + testID={`${testID}-selector`} horizontal={true} showsHorizontalScrollIndicator={false} ref={scrollElRef} @@ -76,6 +77,7 @@ export function TabBar({ const selected = i === selectedPage return ( <PressableWithHover + testID={`${testID}-selector-${i}`} key={item} onLayout={e => onItemLayout(e, i)} style={[styles.item, selected && indicatorStyle]} diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx index 22ff035d0..60afe1f9c 100644 --- a/src/view/com/post-thread/PostLikedBy.tsx +++ b/src/view/com/post-thread/PostLikedBy.tsx @@ -1,39 +1,66 @@ -import React, {useEffect} from 'react' -import {observer} from 'mobx-react-lite' +import React, {useCallback, useMemo, useState} from 'react' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' +import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' import {CenteredView, FlatList} from '../util/Views' -import {LikesModel, LikeItem} from 'state/models/lists/likes' import {ErrorMessage} from '../util/error/ErrorMessage' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {logger} from '#/logger' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {usePostLikedByQuery} from '#/state/queries/post-liked-by' +import {cleanError} from '#/lib/strings/errors' -export const PostLikedBy = observer(function PostLikedByImpl({ - uri, -}: { - uri: string -}) { +export function PostLikedBy({uri}: {uri: string}) { const pal = usePalette('default') - const store = useStores() - const view = React.useMemo(() => new LikesModel(store, {uri}), [store, uri]) + const [isPTRing, setIsPTRing] = useState(false) + const { + data: resolvedUri, + error: resolveError, + isFetching: isFetchingResolvedUri, + } = useResolveUriQuery(uri) + const { + data, + isFetching, + isFetched, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = usePostLikedByQuery(resolvedUri?.uri) + const likes = useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.likes) + } + }, [data]) - useEffect(() => { - view - .loadMore() - .catch(err => logger.error('Failed to fetch likes', {error: err})) - }, [view]) + const onRefresh = useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh likes', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) - const onRefresh = () => { - view.refresh() - } - const onEndReached = () => { - view - .loadMore() - .catch(err => logger.error('Failed to load more likes', {error: err})) - } + const onEndReached = useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more likes', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const renderItem = useCallback(({item}: {item: GetLikes.Like}) => { + return ( + <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} /> + ) + }, []) - if (!view.hasLoaded) { + if (isFetchingResolvedUri || !isFetched) { return ( <CenteredView> <ActivityIndicator /> @@ -43,26 +70,26 @@ export const PostLikedBy = observer(function PostLikedByImpl({ // error // = - if (view.hasError) { + if (resolveError || isError) { return ( <CenteredView> - <ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> + <ErrorMessage + message={cleanError(resolveError || error)} + onPressTryAgain={onRefresh} + /> </CenteredView> ) } // loaded // = - const renderItem = ({item}: {item: LikeItem}) => ( - <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} /> - ) return ( <FlatList - data={view.likes} + data={likes} keyExtractor={item => item.actor.did} refreshControl={ <RefreshControl - refreshing={view.isRefreshing} + refreshing={isPTRing} onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} @@ -75,15 +102,14 @@ export const PostLikedBy = observer(function PostLikedByImpl({ // eslint-disable-next-line react/no-unstable-nested-components ListFooterComponent={() => ( <View style={styles.footer}> - {view.isLoading && <ActivityIndicator />} + {(isFetching || isFetchingNextPage) && <ActivityIndicator />} </View> )} - extraData={view.isLoading} // @ts-ignore our .web version only -prf desktopFixedHeight /> ) -}) +} const styles = StyleSheet.create({ footer: { diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index 29a795302..1162fec40 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -1,42 +1,67 @@ -import React, {useEffect} from 'react' -import {observer} from 'mobx-react-lite' +import React, {useMemo, useCallback, useState} from 'react' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' +import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {CenteredView, FlatList} from '../util/Views' -import {RepostedByModel, RepostedByItem} from 'state/models/lists/reposted-by' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {ErrorMessage} from '../util/error/ErrorMessage' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {logger} from '#/logger' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by' +import {cleanError} from '#/lib/strings/errors' -export const PostRepostedBy = observer(function PostRepostedByImpl({ - uri, -}: { - uri: string -}) { +export function PostRepostedBy({uri}: {uri: string}) { const pal = usePalette('default') - const store = useStores() - const view = React.useMemo( - () => new RepostedByModel(store, {uri}), - [store, uri], - ) + const [isPTRing, setIsPTRing] = useState(false) + const { + data: resolvedUri, + error: resolveError, + isFetching: isFetchingResolvedUri, + } = useResolveUriQuery(uri) + const { + data, + isFetching, + isFetched, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = usePostRepostedByQuery(resolvedUri?.uri) + const repostedBy = useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.repostedBy) + } + }, [data]) - useEffect(() => { - view - .loadMore() - .catch(err => logger.error('Failed to fetch reposts', {error: err})) - }, [view]) + const onRefresh = useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh reposts', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) - const onRefresh = () => { - view.refresh() - } - const onEndReached = () => { - view - .loadMore() - .catch(err => logger.error('Failed to load more reposts', {error: err})) - } + const onEndReached = useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more reposts', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const renderItem = useCallback( + ({item}: {item: ActorDefs.ProfileViewBasic}) => { + return <ProfileCardWithFollowBtn key={item.did} profile={item} /> + }, + [], + ) - if (!view.hasLoaded) { + if (isFetchingResolvedUri || !isFetched) { return ( <CenteredView> <ActivityIndicator /> @@ -46,26 +71,26 @@ export const PostRepostedBy = observer(function PostRepostedByImpl({ // error // = - if (view.hasError) { + if (resolveError || isError) { return ( <CenteredView> - <ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> + <ErrorMessage + message={cleanError(resolveError || error)} + onPressTryAgain={onRefresh} + /> </CenteredView> ) } // loaded // = - const renderItem = ({item}: {item: RepostedByItem}) => ( - <ProfileCardWithFollowBtn key={item.did} profile={item} /> - ) return ( <FlatList - data={view.repostedBy} + data={repostedBy} keyExtractor={item => item.did} refreshControl={ <RefreshControl - refreshing={view.isRefreshing} + refreshing={isPTRing} onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} @@ -78,15 +103,14 @@ export const PostRepostedBy = observer(function PostRepostedByImpl({ // eslint-disable-next-line react/no-unstable-nested-components ListFooterComponent={() => ( <View style={styles.footer}> - {view.isLoading && <ActivityIndicator />} + {(isFetching || isFetchingNextPage) && <ActivityIndicator />} </View> )} - extraData={view.isLoading} // @ts-ignore our .web version only -prf desktopFixedHeight /> ) -}) +} const styles = StyleSheet.create({ footer: { diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 4eb47b0a3..edf02e9c5 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -1,6 +1,4 @@ import React, {useRef} from 'react' -import {runInAction} from 'mobx' -import {observer} from 'mobx-react-lite' import { ActivityIndicator, Pressable, @@ -11,8 +9,6 @@ import { } from 'react-native' import {AppBskyFeedDefs} from '@atproto/api' import {CenteredView, FlatList} from '../util/Views' -import {PostThreadModel} from 'state/models/content/post-thread' -import {PostThreadItemModel} from 'state/models/content/post-thread-item' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -23,43 +19,42 @@ import {ViewHeader} from '../util/ViewHeader' import {ErrorMessage} from '../util/error/ErrorMessage' import {Text} from '../util/text/Text' import {s} from 'lib/styles' -import {isNative} from 'platform/detection' import {usePalette} from 'lib/hooks/usePalette' import {useSetTitle} from 'lib/hooks/useSetTitle' +import { + ThreadNode, + ThreadPost, + usePostThreadQuery, + sortThread, +} from '#/state/queries/post-thread' import {useNavigation} from '@react-navigation/native' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {NavigationProp} from 'lib/routes/types' import {sanitizeDisplayName} from 'lib/strings/display-names' +import {cleanError} from '#/lib/strings/errors' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + UsePreferencesQueryResponse, + usePreferencesQuery, +} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {isNative} from '#/platform/detection' import {logger} from '#/logger' const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} -const TOP_COMPONENT = { - _reactKey: '__top_component__', - _isHighlightedPost: false, -} -const PARENT_SPINNER = { - _reactKey: '__parent_spinner__', - _isHighlightedPost: false, -} -const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false} -const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false} -const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false} -const CHILD_SPINNER = { - _reactKey: '__child_spinner__', - _isHighlightedPost: false, -} -const LOAD_MORE = { - _reactKey: '__load_more__', - _isHighlightedPost: false, -} -const BOTTOM_COMPONENT = { - _reactKey: '__bottom_component__', - _isHighlightedPost: false, - _showBorder: true, -} +const TOP_COMPONENT = {_reactKey: '__top_component__'} +const PARENT_SPINNER = {_reactKey: '__parent_spinner__'} +const REPLY_PROMPT = {_reactKey: '__reply__'} +const DELETED = {_reactKey: '__deleted__'} +const BLOCKED = {_reactKey: '__blocked__'} +const CHILD_SPINNER = {_reactKey: '__child_spinner__'} +const LOAD_MORE = {_reactKey: '__load_more__'} +const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'} + type YieldedItem = - | PostThreadItemModel + | ThreadPost | typeof TOP_COMPONENT | typeof PARENT_SPINNER | typeof REPLY_PROMPT @@ -67,127 +62,161 @@ type YieldedItem = | typeof BLOCKED | typeof PARENT_SPINNER -export const PostThread = observer(function PostThread({ +export function PostThread({ uri, - view, onPressReply, - treeView, }: { - uri: string - view: PostThreadModel + uri: string | undefined + onPressReply: () => void +}) { + const { + isLoading, + isError, + error, + refetch, + data: thread, + } = usePostThreadQuery(uri) + const {data: preferences} = usePreferencesQuery() + const rootPost = thread?.type === 'post' ? thread.post : undefined + const rootPostRecord = thread?.type === 'post' ? thread.record : undefined + + useSetTitle( + rootPost && + `${sanitizeDisplayName( + rootPost.author.displayName || `@${rootPost.author.handle}`, + )}: "${rootPostRecord?.text}"`, + ) + + if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) { + return ( + <PostThreadError + error={error} + notFound={AppBskyFeedDefs.isNotFoundPost(thread)} + onRefresh={refetch} + /> + ) + } + if (AppBskyFeedDefs.isBlockedPost(thread)) { + return <PostThreadBlocked /> + } + if (!thread || isLoading || !preferences) { + return ( + <CenteredView> + <View style={s.p20}> + <ActivityIndicator size="large" /> + </View> + </CenteredView> + ) + } + return ( + <PostThreadLoaded + thread={thread} + threadViewPrefs={preferences.threadViewPrefs} + onRefresh={refetch} + onPressReply={onPressReply} + /> + ) +} + +function PostThreadLoaded({ + thread, + threadViewPrefs, + onRefresh, + onPressReply, +}: { + thread: ThreadNode + threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs'] + onRefresh: () => void onPressReply: () => void - treeView: boolean }) { + const {hasSession} = useSession() + const {_} = useLingui() const pal = usePalette('default') const {isTablet, isDesktop} = useWebMediaQueries() const ref = useRef<FlatList>(null) - const hasScrolledIntoView = useRef<boolean>(false) - const [isRefreshing, setIsRefreshing] = React.useState(false) + const highlightedPostRef = useRef<View | null>(null) + const needsScrollAdjustment = useRef<boolean>( + !isNative || // web always uses scroll adjustment + (thread.type === 'post' && !thread.ctx.isParentLoading), // native only does it when not loading from placeholder + ) const [maxVisible, setMaxVisible] = React.useState(100) - const navigation = useNavigation<NavigationProp>() + const [isPTRing, setIsPTRing] = React.useState(false) + + // construct content const posts = React.useMemo(() => { - if (view.thread) { - let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread))) - if (arr.length > maxVisible) { - arr = arr.slice(0, maxVisible).concat([LOAD_MORE]) - } - if (view.isLoadingFromCache) { - if (view.thread?.postRecord?.reply) { - arr.unshift(PARENT_SPINNER) - } - arr.push(CHILD_SPINNER) - } else { - arr.push(BOTTOM_COMPONENT) - } - return arr + let arr = [TOP_COMPONENT].concat( + Array.from(flattenThreadSkeleton(sortThread(thread, threadViewPrefs))), + ) + if (arr.length > maxVisible) { + arr = arr.slice(0, maxVisible).concat([LOAD_MORE]) } - return [] - }, [view.isLoadingFromCache, view.thread, maxVisible]) - const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost) - useSetTitle( - view.thread?.postRecord && - `${sanitizeDisplayName( - view.thread.post.author.displayName || - `@${view.thread.post.author.handle}`, - )}: "${view.thread?.postRecord?.text}"`, - ) - - // events - // = - - const onRefresh = React.useCallback(async () => { - setIsRefreshing(true) - try { - view?.refresh() - } catch (err) { - logger.error('Failed to refresh posts thread', {error: err}) + if (arr.indexOf(CHILD_SPINNER) === -1) { + arr.push(BOTTOM_COMPONENT) } - setIsRefreshing(false) - }, [view, setIsRefreshing]) + return arr + }, [thread, maxVisible, threadViewPrefs]) + /** + * NOTE + * Scroll positioning + * + * This callback is run if needsScrollAdjustment.current == true, which is... + * - On web: always + * - On native: when the placeholder cache is not being used + * + * It then only runs when viewing a reply, and the goal is to scroll the + * reply into view. + * + * On native, if the placeholder cache is being used then maintainVisibleContentPosition + * is a more effective solution, so we use that. Otherwise, typically we're loading from + * the react-query cache, so we just need to immediately scroll down to the post. + * + * On desktop, maintainVisibleContentPosition isn't supported so we just always use + * this technique. + * + * -prf + */ const onContentSizeChange = React.useCallback(() => { // only run once - if (hasScrolledIntoView.current) { + if (!needsScrollAdjustment.current) { return } // wait for loading to finish - if ( - !view.hasContent || - (view.isFromCache && view.isLoadingFromCache) || - view.isLoading - ) { - return + 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), + }) + }, + ) + needsScrollAdjustment.current = false } + }, [thread, isDesktop]) - if (highlightedPostIndex !== -1) { - ref.current?.scrollToIndex({ - index: highlightedPostIndex, - animated: false, - viewPosition: 0, - }) - hasScrolledIntoView.current = true - } - }, [ - highlightedPostIndex, - view.hasContent, - view.isFromCache, - view.isLoadingFromCache, - view.isLoading, - ]) - const onScrollToIndexFailed = React.useCallback( - (info: { - index: number - highestMeasuredFrameIndex: number - averageItemLength: number - }) => { - ref.current?.scrollToOffset({ - animated: false, - offset: info.averageItemLength * info.index, - }) - }, - [ref], - ) - - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') + const onPTR = React.useCallback(async () => { + setIsPTRing(true) + try { + await onRefresh() + } catch (err) { + logger.error('Failed to refresh posts thread', {error: err}) } - }, [navigation]) + setIsPTRing(false) + }, [setIsPTRing, onRefresh]) const renderItem = React.useCallback( ({item, index}: {item: YieldedItem; index: number}) => { if (item === TOP_COMPONENT) { - return isTablet ? <ViewHeader title="Post" /> : null + return isTablet ? <ViewHeader title={_(msg`Post`)} /> : null } else if (item === PARENT_SPINNER) { return ( <View style={styles.parentSpinner}> <ActivityIndicator /> </View> ) - } else if (item === REPLY_PROMPT) { + } else if (item === REPLY_PROMPT && hasSession) { return ( <View> {isDesktop && <ComposePrompt onPressCompose={onPressReply} />} @@ -197,7 +226,7 @@ export const PostThread = observer(function PostThread({ return ( <View style={[pal.border, pal.viewLight, styles.itemContainer]}> <Text type="lg-bold" style={pal.textLight}> - Deleted post. + <Trans>Deleted post.</Trans> </Text> </View> ) @@ -205,7 +234,7 @@ export const PostThread = observer(function PostThread({ return ( <View style={[pal.border, pal.viewLight, styles.itemContainer]}> <Text type="lg-bold" style={pal.textLight}> - Blocked post. + <Trans>Blocked post.</Trans> </Text> </View> ) @@ -214,7 +243,7 @@ export const PostThread = observer(function PostThread({ <Pressable onPress={() => setMaxVisible(n => n + 50)} style={[pal.border, pal.view, styles.itemContainer]} - accessibilityLabel="Load more posts" + accessibilityLabel={_(msg`Load more posts`)} accessibilityHint=""> <View style={[ @@ -222,7 +251,7 @@ export const PostThread = observer(function PostThread({ {paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6}, ]}> <Text type="lg-medium" style={pal.text}> - Load more posts + <Trans>Load more posts</Trans> </Text> </View> </Pressable> @@ -247,22 +276,32 @@ export const PostThread = observer(function PostThread({ <ActivityIndicator /> </View> ) - } else if (item instanceof PostThreadItemModel) { - const prev = ( - index - 1 >= 0 ? posts[index - 1] : undefined - ) as PostThreadItemModel + } else if (isThreadPost(item)) { + const prev = isThreadPost(posts[index - 1]) + ? (posts[index - 1] as ThreadPost) + : undefined return ( - <PostThreadItem - item={item} - onPostReply={onRefresh} - hasPrecedingItem={prev?._showChildReplyLine} - treeView={treeView} - /> + <View + ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}> + <PostThreadItem + post={item.post} + record={item.record} + treeView={threadViewPrefs.lab_treeViewEnabled || false} + depth={item.ctx.depth} + isHighlightedPost={item.ctx.isHighlightedPost} + hasMore={item.ctx.hasMore} + showChildReplyLine={item.ctx.showChildReplyLine} + showParentReplyLine={item.ctx.showParentReplyLine} + hasPrecedingItem={!!prev?.ctx.showChildReplyLine} + onPostReply={onRefresh} + /> + </View> ) } - return <></> + return null }, [ + hasSession, isTablet, isDesktop, onPressReply, @@ -274,77 +313,117 @@ export const PostThread = observer(function PostThread({ pal.colors.border, posts, onRefresh, - treeView, + threadViewPrefs.lab_treeViewEnabled, + _, ], ) - // loading - // = - if ( - !view.hasLoaded || - (view.isLoading && !view.isRefreshing) || - view.params.uri !== uri - ) { - return ( - <CenteredView> - <View style={s.p20}> - <ActivityIndicator size="large" /> - </View> - </CenteredView> - ) - } + return ( + <FlatList + ref={ref} + data={posts} + initialNumToRender={posts.length} + maintainVisibleContentPosition={ + !needsScrollAdjustment.current + ? MAINTAIN_VISIBLE_CONTENT_POSITION + : undefined + } + keyExtractor={item => item._reactKey} + renderItem={renderItem} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onPTR} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onContentSizeChange={onContentSizeChange} + style={s.hContentRegion} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + ) +} - // error - // = - if (view.hasError) { - if (view.notFound) { - return ( - <CenteredView> - <View style={[pal.view, pal.border, styles.notFoundContainer]}> - <Text type="title-lg" style={[pal.text, s.mb5]}> - Post not found - </Text> - <Text type="md" style={[pal.text, s.mb10]}> - The post may have been deleted. - </Text> - <TouchableOpacity - onPress={onPressBack} - accessibilityRole="button" - accessibilityLabel="Back" - accessibilityHint=""> - <Text type="2xl" style={pal.link}> - <FontAwesomeIcon - icon="angle-left" - style={[pal.link as FontAwesomeIconStyle, s.mr5]} - size={14} - /> - Back - </Text> - </TouchableOpacity> - </View> - </CenteredView> - ) +function PostThreadBlocked() { + const {_} = useLingui() + const pal = usePalette('default') + const navigation = useNavigation<NavigationProp>() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') } - return ( - <CenteredView> - <ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> - </CenteredView> - ) - } - if (view.isBlocked) { + }, [navigation]) + + return ( + <CenteredView> + <View style={[pal.view, pal.border, styles.notFoundContainer]}> + <Text type="title-lg" style={[pal.text, s.mb5]}> + <Trans>Post hidden</Trans> + </Text> + <Text type="md" style={[pal.text, s.mb10]}> + <Trans> + You have blocked the author or you have been blocked by the author. + </Trans> + </Text> + <TouchableOpacity + onPress={onPressBack} + accessibilityRole="button" + accessibilityLabel={_(msg`Back`)} + accessibilityHint=""> + <Text type="2xl" style={pal.link}> + <FontAwesomeIcon + icon="angle-left" + style={[pal.link as FontAwesomeIconStyle, s.mr5]} + size={14} + /> + Back + </Text> + </TouchableOpacity> + </View> + </CenteredView> + ) +} + +function PostThreadError({ + onRefresh, + notFound, + error, +}: { + onRefresh: () => void + notFound: boolean + error: Error | null +}) { + const {_} = useLingui() + const pal = usePalette('default') + const navigation = useNavigation<NavigationProp>() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + if (notFound) { return ( <CenteredView> <View style={[pal.view, pal.border, styles.notFoundContainer]}> <Text type="title-lg" style={[pal.text, s.mb5]}> - Post hidden + <Trans>Post not found</Trans> </Text> <Text type="md" style={[pal.text, s.mb10]}> - You have blocked the author or you have been blocked by the author. + <Trans>The post may have been deleted.</Trans> </Text> <TouchableOpacity onPress={onPressBack} accessibilityRole="button" - accessibilityLabel="Back" + accessibilityLabel={_(msg`Back`)} accessibilityHint=""> <Text type="2xl" style={pal.link}> <FontAwesomeIcon @@ -352,76 +431,48 @@ export const PostThread = observer(function PostThread({ style={[pal.link as FontAwesomeIconStyle, s.mr5]} size={14} /> - Back + <Trans>Back</Trans> </Text> </TouchableOpacity> </View> </CenteredView> ) } - - // loaded - // = return ( - <FlatList - ref={ref} - data={posts} - initialNumToRender={posts.length} - maintainVisibleContentPosition={ - isNative && view.isFromCache && view.isCachedPostAReply - ? MAINTAIN_VISIBLE_CONTENT_POSITION - : undefined - } - keyExtractor={item => item._reactKey} - renderItem={renderItem} - refreshControl={ - <RefreshControl - refreshing={isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - onContentSizeChange={ - isNative && view.isFromCache ? undefined : onContentSizeChange - } - onScrollToIndexFailed={onScrollToIndexFailed} - style={s.hContentRegion} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> + <CenteredView> + <ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} /> + </CenteredView> ) -}) +} + +function isThreadPost(v: unknown): v is ThreadPost { + return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' +} -function* flattenThread( - post: PostThreadItemModel, - isAscending = false, +function* flattenThreadSkeleton( + node: ThreadNode, ): Generator<YieldedItem, void> { - if (post.parent) { - if (AppBskyFeedDefs.isNotFoundPost(post.parent)) { - yield DELETED - } else if (AppBskyFeedDefs.isBlockedPost(post.parent)) { - yield BLOCKED - } else { - yield* flattenThread(post.parent as PostThreadItemModel, true) + if (node.type === 'post') { + if (node.parent) { + yield* flattenThreadSkeleton(node.parent) + } else if (node.ctx.isParentLoading) { + yield PARENT_SPINNER } - } - yield post - if (post._isHighlightedPost) { - yield REPLY_PROMPT - } - if (post.replies?.length) { - for (const reply of post.replies) { - if (AppBskyFeedDefs.isNotFoundPost(reply)) { - yield DELETED - } else { - yield* flattenThread(reply as PostThreadItemModel) + yield node + if (node.ctx.isHighlightedPost) { + yield REPLY_PROMPT + } + if (node.replies?.length) { + for (const reply of node.replies) { + yield* flattenThreadSkeleton(reply) } + } else if (node.ctx.isChildLoading) { + yield CHILD_SPINNER } - } else if (!isAscending && !post.parent && post.post.replyCount) { - runInAction(() => { - post._hasMore = true - }) + } else if (node.type === 'not-found') { + yield DELETED + } else if (node.type === 'blocked') { + yield BLOCKED } } diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 351a46706..a4b7a4a9c 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -1,18 +1,17 @@ -import React, {useMemo} from 'react' -import {observer} from 'mobx-react-lite' -import {Linking, StyleSheet, View} from 'react-native' -import Clipboard from '@react-native-clipboard/clipboard' -import {AtUri, AppBskyFeedDefs} from '@atproto/api' +import React, {memo, useMemo} from 'react' +import {StyleSheet, View} from 'react-native' import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {PostThreadItemModel} from 'state/models/content/post-thread-item' + AtUri, + AppBskyFeedDefs, + AppBskyFeedPost, + RichText as RichTextAPI, + moderatePost, + PostModeration, +} from '@atproto/api' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Link, TextLink} from '../util/Link' import {RichText} from '../util/text/RichText' import {Text} from '../util/text/Text' -import {PostDropdownBtn} from '../util/forms/PostDropdownBtn' -import * as Toast from '../util/Toast' import {PreviewableUserAvatar} from '../util/UserAvatar' import {s} from 'lib/styles' import {niceDate} from 'lib/strings/time' @@ -21,10 +20,10 @@ import {sanitizeHandle} from 'lib/strings/handles' import {countLines, pluralize} from 'lib/strings/helpers' import {isEmbedByEmbedder} from 'lib/embeds' import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' -import {useStores} from 'state/index' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/post-embeds' import {PostCtrls} from '../util/post-ctrls/PostCtrls' +import {PostDropdownBtn} from '../util/forms/PostDropdownBtn' import {PostHider} from '../util/moderation/PostHider' import {ContentHider} from '../util/moderation/ContentHider' import {PostAlerts} from '../util/moderation/PostAlerts' @@ -36,125 +35,172 @@ import {TimeElapsed} from 'view/com/util/TimeElapsed' import {makeProfileLink} from 'lib/routes/links' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {MAX_POST_LINES} from 'lib/constants' -import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useLanguagePrefs} from '#/state/preferences' +import {useComposerControls} from '#/state/shell/composer' +import {useModerationOpts} from '#/state/queries/preferences' +import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' -export const PostThreadItem = observer(function PostThreadItem({ - item, - onPostReply, - hasPrecedingItem, +export function PostThreadItem({ + post, + record, treeView, + depth, + isHighlightedPost, + hasMore, + showChildReplyLine, + showParentReplyLine, + hasPrecedingItem, + onPostReply, }: { - item: PostThreadItemModel - onPostReply: () => void - hasPrecedingItem: boolean + post: AppBskyFeedDefs.PostView + record: AppBskyFeedPost.Record treeView: boolean + depth: number + isHighlightedPost?: boolean + hasMore?: boolean + showChildReplyLine?: boolean + showParentReplyLine?: boolean + hasPrecedingItem: boolean + onPostReply: () => void }) { + const moderationOpts = useModerationOpts() + const postShadowed = usePostShadow(post) + const richText = useMemo( + () => + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], + ) + const moderation = useMemo( + () => + post && moderationOpts ? moderatePost(post, moderationOpts) : undefined, + [post, moderationOpts], + ) + if (postShadowed === POST_TOMBSTONE) { + return <PostThreadItemDeleted /> + } + if (richText && moderation) { + return ( + <PostThreadItemLoaded + post={postShadowed} + record={record} + richText={richText} + moderation={moderation} + treeView={treeView} + depth={depth} + isHighlightedPost={isHighlightedPost} + hasMore={hasMore} + showChildReplyLine={showChildReplyLine} + showParentReplyLine={showParentReplyLine} + hasPrecedingItem={hasPrecedingItem} + onPostReply={onPostReply} + /> + ) + } + return null +} + +function PostThreadItemDeleted() { + const styles = useStyles() + const pal = usePalette('default') + return ( + <View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}> + <FontAwesomeIcon icon={['far', 'trash-can']} color={pal.colors.icon} /> + <Text style={[pal.textLight, s.ml10]}> + <Trans>This post has been deleted.</Trans> + </Text> + </View> + ) +} + +let PostThreadItemLoaded = ({ + post, + record, + richText, + moderation, + treeView, + depth, + isHighlightedPost, + hasMore, + showChildReplyLine, + showParentReplyLine, + hasPrecedingItem, + onPostReply, +}: { + post: Shadow<AppBskyFeedDefs.PostView> + record: AppBskyFeedPost.Record + richText: RichTextAPI + moderation: PostModeration + treeView: boolean + depth: number + isHighlightedPost?: boolean + hasMore?: boolean + showChildReplyLine?: boolean + showParentReplyLine?: boolean + hasPrecedingItem: boolean + onPostReply: () => void +}): React.ReactNode => { const pal = usePalette('default') - const store = useStores() - const [deleted, setDeleted] = React.useState(false) + const langPrefs = useLanguagePrefs() + const {openComposer} = useComposerControls() const [limitLines, setLimitLines] = React.useState( - countLines(item.richText?.text) >= MAX_POST_LINES, + () => countLines(richText?.text) >= MAX_POST_LINES, ) const styles = useStyles() - const record = item.postRecord - const hasEngagement = item.post.likeCount || item.post.repostCount + const hasEngagement = post.likeCount || post.repostCount - const itemUri = item.post.uri - const itemCid = item.post.cid - const itemHref = React.useMemo(() => { - const urip = new AtUri(item.post.uri) - return makeProfileLink(item.post.author, 'post', urip.rkey) - }, [item.post.uri, item.post.author]) - const itemTitle = `Post by ${item.post.author.handle}` - const authorHref = makeProfileLink(item.post.author) - const authorTitle = item.post.author.handle - const isAuthorMuted = item.post.author.viewer?.muted + const rootUri = record.reply?.root?.uri || post.uri + const postHref = React.useMemo(() => { + 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 authorHref = makeProfileLink(post.author) + const authorTitle = post.author.handle + const isAuthorMuted = post.author.viewer?.muted const likesHref = React.useMemo(() => { - const urip = new AtUri(item.post.uri) - return makeProfileLink(item.post.author, 'post', urip.rkey, 'liked-by') - }, [item.post.uri, item.post.author]) + 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 repostsHref = React.useMemo(() => { - const urip = new AtUri(item.post.uri) - return makeProfileLink(item.post.author, 'post', urip.rkey, 'reposted-by') - }, [item.post.uri, item.post.author]) + 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 translatorUrl = getTranslatorLink( record?.text || '', - store.preferences.primaryLanguage, + langPrefs.primaryLanguage, ) const needsTranslation = useMemo( () => Boolean( - store.preferences.primaryLanguage && - !isPostInLanguage(item.post, [store.preferences.primaryLanguage]), + langPrefs.primaryLanguage && + !isPostInLanguage(post, [langPrefs.primaryLanguage]), ), - [item.post, store.preferences.primaryLanguage], + [post, langPrefs.primaryLanguage], ) const onPressReply = React.useCallback(() => { - store.shell.openComposer({ + openComposer({ replyTo: { - uri: item.post.uri, - cid: item.post.cid, - text: record?.text as string, + uri: post.uri, + cid: post.cid, + text: record.text, author: { - handle: item.post.author.handle, - displayName: item.post.author.displayName, - avatar: item.post.author.avatar, + handle: post.author.handle, + displayName: post.author.displayName, + avatar: post.author.avatar, }, }, onPost: onPostReply, }) - }, [store, item, record, onPostReply]) - - const onPressToggleRepost = React.useCallback(() => { - return item - .toggleRepost() - .catch(e => logger.error('Failed to toggle repost', {error: e})) - }, [item]) - - const onPressToggleLike = React.useCallback(() => { - return item - .toggleLike() - .catch(e => logger.error('Failed to toggle like', {error: e})) - }, [item]) - - const onCopyPostText = React.useCallback(() => { - Clipboard.setString(record?.text || '') - Toast.show('Copied to clipboard') - }, [record]) - - const onOpenTranslate = React.useCallback(() => { - Linking.openURL(translatorUrl) - }, [translatorUrl]) - - const onToggleThreadMute = React.useCallback(async () => { - try { - await item.toggleThreadMute() - if (item.isThreadMuted) { - Toast.show('You will no longer receive notifications for this thread') - } else { - Toast.show('You will now receive notifications for this thread') - } - } catch (e) { - logger.error('Failed to toggle thread mute', {error: e}) - } - }, [item]) - - const onDeletePost = React.useCallback(() => { - item.delete().then( - () => { - setDeleted(true) - Toast.show('Post deleted') - }, - e => { - logger.error('Failed to delete post', {error: e}) - Toast.show('Failed to delete post, please try again') - }, - ) - }, [item]) + }, [openComposer, post, record, onPostReply]) const onPressShowMore = React.useCallback(() => { setLimitLines(false) @@ -164,22 +210,10 @@ export const PostThreadItem = observer(function PostThreadItem({ return <ErrorMessage message="Invalid or unsupported post record" /> } - if (deleted) { - return ( - <View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}> - <FontAwesomeIcon - icon={['far', 'trash-can']} - style={pal.icon as FontAwesomeIconStyle} - /> - <Text style={[pal.textLight, s.ml10]}>This post has been deleted.</Text> - </View> - ) - } - - if (item._isHighlightedPost) { + if (isHighlightedPost) { return ( <> - {item.rootUri !== item.uri && ( + {rootUri !== post.uri && ( <View style={{paddingLeft: 16, flexDirection: 'row', height: 16}}> <View style={{width: 38}}> <View @@ -196,7 +230,7 @@ export const PostThreadItem = observer(function PostThreadItem({ )} <Link - testID={`postThreadItem-by-${item.post.author.handle}`} + testID={`postThreadItem-by-${post.author.handle}`} style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} noFeedback accessible={false}> @@ -205,10 +239,10 @@ export const PostThreadItem = observer(function PostThreadItem({ <View style={[styles.layoutAvi, {paddingBottom: 8}]}> <PreviewableUserAvatar size={52} - did={item.post.author.did} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - moderation={item.moderation.avatar} + did={post.author.did} + handle={post.author.handle} + avatar={post.author.avatar} + moderation={moderation.avatar} /> </View> <View style={styles.layoutContent}> @@ -225,17 +259,17 @@ export const PostThreadItem = observer(function PostThreadItem({ numberOfLines={1} lineHeight={1.2}> {sanitizeDisplayName( - item.post.author.displayName || - sanitizeHandle(item.post.author.handle), + post.author.displayName || + sanitizeHandle(post.author.handle), )} </Text> </Link> - <TimeElapsed timestamp={item.post.indexedAt}> + <TimeElapsed timestamp={post.indexedAt}> {({timeElapsed}) => ( <Text type="md" style={[styles.metaItem, pal.textLight]} - title={niceDate(item.post.indexedAt)}> + title={niceDate(post.indexedAt)}> · {timeElapsed} </Text> )} @@ -272,23 +306,15 @@ export const PostThreadItem = observer(function PostThreadItem({ href={authorHref} title={authorTitle}> <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {sanitizeHandle(item.post.author.handle, '@')} + {sanitizeHandle(post.author.handle, '@')} </Text> </Link> </View> </View> <PostDropdownBtn testID="postDropdownBtn" - itemUri={itemUri} - itemCid={itemCid} - itemHref={itemHref} - itemTitle={itemTitle} - isAuthor={item.post.author.did === store.me.did} - isThreadMuted={item.isThreadMuted} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onToggleThreadMute={onToggleThreadMute} - onDeletePost={onDeletePost} + post={post} + record={record} style={{ paddingVertical: 6, paddingHorizontal: 10, @@ -299,16 +325,16 @@ export const PostThreadItem = observer(function PostThreadItem({ </View> <View style={[s.pl10, s.pr10, s.pb10]}> <ContentHider - moderation={item.moderation.content} + moderation={moderation.content} ignoreMute style={styles.contentHider} childContainerStyle={styles.contentHiderChild}> <PostAlerts - moderation={item.moderation.content} + moderation={moderation.content} includeMute style={styles.alert} /> - {item.richText?.text ? ( + {richText?.text ? ( <View style={[ styles.postTextContainer, @@ -316,59 +342,56 @@ export const PostThreadItem = observer(function PostThreadItem({ ]}> <RichText type="post-text-lg" - richText={item.richText} + richText={richText} lineHeight={1.3} style={s.flex1} /> </View> ) : undefined} - {item.post.embed && ( + {post.embed && ( <ContentHider - moderation={item.moderation.embed} - ignoreMute={isEmbedByEmbedder( - item.post.embed, - item.post.author.did, - )} + moderation={moderation.embed} + ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)} style={s.mb10}> <PostEmbeds - embed={item.post.embed} - moderation={item.moderation.embed} + embed={post.embed} + moderation={moderation.embed} /> </ContentHider> )} </ContentHider> <ExpandedPostDetails - post={item.post} + post={post} translatorUrl={translatorUrl} needsTranslation={needsTranslation} /> {hasEngagement ? ( <View style={[styles.expandedInfo, pal.border]}> - {item.post.repostCount ? ( + {post.repostCount ? ( <Link style={styles.expandedInfoItem} href={repostsHref} title={repostsTitle}> <Text testID="repostCount" type="lg" style={pal.textLight}> <Text type="xl-bold" style={pal.text}> - {formatCount(item.post.repostCount)} + {formatCount(post.repostCount)} </Text>{' '} - {pluralize(item.post.repostCount, 'repost')} + {pluralize(post.repostCount, 'repost')} </Text> </Link> ) : ( <></> )} - {item.post.likeCount ? ( + {post.likeCount ? ( <Link style={styles.expandedInfoItem} href={likesHref} title={likesTitle}> <Text testID="likeCount" type="lg" style={pal.textLight}> <Text type="xl-bold" style={pal.text}> - {formatCount(item.post.likeCount)} + {formatCount(post.likeCount)} </Text>{' '} - {pluralize(item.post.likeCount, 'like')} + {pluralize(post.likeCount, 'like')} </Text> </Link> ) : ( @@ -381,24 +404,9 @@ export const PostThreadItem = observer(function PostThreadItem({ <View style={[s.pl10, s.pb5]}> <PostCtrls big - itemUri={itemUri} - itemCid={itemCid} - itemHref={itemHref} - itemTitle={itemTitle} - author={item.post.author} - text={item.richText?.text || record.text} - indexedAt={item.post.indexedAt} - isAuthor={item.post.author.did === store.me.did} - isReposted={!!item.post.viewer?.repost} - isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} + post={post} + record={record} onPressReply={onPressReply} - onPressToggleRepost={onPressToggleRepost} - onPressToggleLike={onPressToggleLike} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onToggleThreadMute={onToggleThreadMute} - onDeletePost={onDeletePost} /> </View> </View> @@ -406,17 +414,19 @@ export const PostThreadItem = observer(function PostThreadItem({ </> ) } else { - const isThreadedChild = treeView && item._depth > 1 + const isThreadedChild = treeView && depth > 1 return ( <PostOuterWrapper - item={item} - hasPrecedingItem={hasPrecedingItem} - treeView={treeView}> + post={post} + depth={depth} + showParentReplyLine={!!showParentReplyLine} + treeView={treeView} + hasPrecedingItem={hasPrecedingItem}> <PostHider - testID={`postThreadItem-by-${item.post.author.handle}`} - href={itemHref} + testID={`postThreadItem-by-${post.author.handle}`} + href={postHref} style={[pal.view]} - moderation={item.moderation.content}> + moderation={moderation.content}> <PostSandboxWarning /> <View @@ -427,7 +437,7 @@ export const PostThreadItem = observer(function PostThreadItem({ height: isThreadedChild ? 8 : 16, }}> <View style={{width: 38}}> - {!isThreadedChild && item._showParentReplyLine && ( + {!isThreadedChild && showParentReplyLine && ( <View style={[ styles.replyLine, @@ -446,21 +456,20 @@ export const PostThreadItem = observer(function PostThreadItem({ style={[ styles.layout, { - paddingBottom: - item._showChildReplyLine && !isThreadedChild ? 0 : 8, + paddingBottom: showChildReplyLine && !isThreadedChild ? 0 : 8, }, ]}> {!isThreadedChild && ( <View style={styles.layoutAvi}> <PreviewableUserAvatar size={38} - did={item.post.author.did} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - moderation={item.moderation.avatar} + did={post.author.did} + handle={post.author.handle} + avatar={post.author.avatar} + moderation={moderation.avatar} /> - {item._showChildReplyLine && ( + {showChildReplyLine && ( <View style={[ styles.replyLine, @@ -477,10 +486,10 @@ export const PostThreadItem = observer(function PostThreadItem({ <View style={styles.layoutContent}> <PostMeta - author={item.post.author} - authorHasWarning={!!item.post.author.labels?.length} - timestamp={item.post.indexedAt} - postHref={itemHref} + author={post.author} + authorHasWarning={!!post.author.labels?.length} + timestamp={post.indexedAt} + postHref={postHref} showAvatar={isThreadedChild} avatarSize={26} displayNameType="md-bold" @@ -488,14 +497,14 @@ export const PostThreadItem = observer(function PostThreadItem({ style={isThreadedChild && s.mb5} /> <PostAlerts - moderation={item.moderation.content} + moderation={moderation.content} style={styles.alert} /> - {item.richText?.text ? ( + {richText?.text ? ( <View style={styles.postTextContainer}> <RichText type="post-text" - richText={item.richText} + richText={richText} style={[pal.text, s.flex1]} lineHeight={1.3} numberOfLines={limitLines ? MAX_POST_LINES : undefined} @@ -510,42 +519,24 @@ export const PostThreadItem = observer(function PostThreadItem({ href="#" /> ) : undefined} - {item.post.embed && ( + {post.embed && ( <ContentHider style={styles.contentHider} - moderation={item.moderation.embed}> + moderation={moderation.embed}> <PostEmbeds - embed={item.post.embed} - moderation={item.moderation.embed} + embed={post.embed} + moderation={moderation.embed} /> </ContentHider> )} <PostCtrls - itemUri={itemUri} - itemCid={itemCid} - itemHref={itemHref} - itemTitle={itemTitle} - author={item.post.author} - text={item.richText?.text || record.text} - indexedAt={item.post.indexedAt} - isAuthor={item.post.author.did === store.me.did} - replyCount={item.post.replyCount} - repostCount={item.post.repostCount} - likeCount={item.post.likeCount} - isReposted={!!item.post.viewer?.repost} - isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} + post={post} + record={record} onPressReply={onPressReply} - onPressToggleRepost={onPressToggleRepost} - onPressToggleLike={onPressToggleLike} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onToggleThreadMute={onToggleThreadMute} - onDeletePost={onDeletePost} /> </View> </View> - {item._hasMore ? ( + {hasMore ? ( <Link style={[ styles.loadMore, @@ -555,7 +546,7 @@ export const PostThreadItem = observer(function PostThreadItem({ paddingBottom: treeView ? 4 : 12, }, ]} - href={itemHref} + href={postHref} title={itemTitle} noFeedback> <Text type="sm-medium" style={pal.textLight}> @@ -572,22 +563,27 @@ export const PostThreadItem = observer(function PostThreadItem({ </PostOuterWrapper> ) } -}) +} +PostThreadItemLoaded = memo(PostThreadItemLoaded) function PostOuterWrapper({ - item, - hasPrecedingItem, + post, treeView, + depth, + showParentReplyLine, + hasPrecedingItem, children, }: React.PropsWithChildren<{ - item: PostThreadItemModel - hasPrecedingItem: boolean + post: AppBskyFeedDefs.PostView treeView: boolean + depth: number + showParentReplyLine: boolean + hasPrecedingItem: boolean }>) { const {isMobile} = useWebMediaQueries() const pal = usePalette('default') const styles = useStyles() - if (treeView && item._depth > 1) { + if (treeView && depth > 1) { return ( <View style={[ @@ -597,13 +593,13 @@ function PostOuterWrapper({ { flexDirection: 'row', paddingLeft: 20, - borderTopWidth: item._depth === 1 ? 1 : 0, - paddingTop: item._depth === 1 ? 8 : 0, + borderTopWidth: depth === 1 ? 1 : 0, + paddingTop: depth === 1 ? 8 : 0, }, ]}> - {Array.from(Array(item._depth - 1)).map((_, n: number) => ( + {Array.from(Array(depth - 1)).map((_, n: number) => ( <View - key={`${item.uri}-padding-${n}`} + key={`${post.uri}-padding-${n}`} style={{ borderLeftWidth: 2, borderLeftColor: pal.colors.border, @@ -622,7 +618,7 @@ function PostOuterWrapper({ styles.outer, pal.view, pal.border, - item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder, + showParentReplyLine && hasPrecedingItem && styles.noTopBorder, styles.cursor, ]}> {children} @@ -640,14 +636,17 @@ function ExpandedPostDetails({ translatorUrl: string }) { const pal = usePalette('default') + const {_} = useLingui() return ( <View style={[s.flexRow, s.mt2, s.mb10]}> <Text style={pal.textLight}>{niceDate(post.indexedAt)}</Text> {needsTranslation && ( <> - <Text style={pal.textLight}> • </Text> - <Link href={translatorUrl} title="Translate"> - <Text style={pal.link}>Translate</Text> + <Text style={[pal.textLight, s.ml5, s.mr5]}>•</Text> + <Link href={translatorUrl} title={_(msg`Translate`)}> + <Text style={pal.link}> + <Trans>Translate</Trans> + </Text> </Link> </> )} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 4ec9db77f..2e8019e71 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -1,19 +1,14 @@ -import React, {useState} from 'react' +import React, {useState, useMemo} from 'react' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import { - ActivityIndicator, - Linking, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native' -import {AppBskyFeedPost as FeedPost} from '@atproto/api' -import {observer} from 'mobx-react-lite' -import Clipboard from '@react-native-clipboard/clipboard' -import {AtUri} from '@atproto/api' + AppBskyFeedDefs, + AppBskyFeedPost, + AtUri, + moderatePost, + PostModeration, + RichText as RichTextAPI, +} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {PostThreadModel} from 'state/models/content/post-thread' -import {PostThreadItemModel} from 'state/models/content/post-thread-item' import {Link, TextLink} from '../util/Link' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' @@ -23,169 +18,109 @@ import {ContentHider} from '../util/moderation/ContentHider' import {PostAlerts} from '../util/moderation/PostAlerts' import {Text} from '../util/text/Text' import {RichText} from '../util/text/RichText' -import * as Toast from '../util/Toast' import {PreviewableUserAvatar} from '../util/UserAvatar' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {getTranslatorLink} from '../../../locale/helpers' import {makeProfileLink} from 'lib/routes/links' import {MAX_POST_LINES} from 'lib/constants' import {countLines} from 'lib/strings/helpers' -import {logger} from '#/logger' +import {useModerationOpts} from '#/state/queries/preferences' +import {useComposerControls} from '#/state/shell/composer' +import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' -export const Post = observer(function PostImpl({ - view, +export function Post({ + post, showReplyLine, - hideError, style, }: { - view: PostThreadModel + post: AppBskyFeedDefs.PostView showReplyLine?: boolean - hideError?: boolean style?: StyleProp<ViewStyle> }) { - const pal = usePalette('default') - const [deleted, setDeleted] = useState(false) - - // deleted - // = - if (deleted) { - return <View /> - } - - // loading - // = - if (!view.hasContent && view.isLoading) { - return ( - <View style={pal.view}> - <ActivityIndicator /> - </View> - ) + const moderationOpts = useModerationOpts() + const record = useMemo<AppBskyFeedPost.Record | undefined>( + () => + AppBskyFeedPost.isRecord(post.record) && + AppBskyFeedPost.validateRecord(post.record).success + ? post.record + : undefined, + [post], + ) + const postShadowed = usePostShadow(post) + const richText = useMemo( + () => + record + ? new RichTextAPI({ + text: record.text, + facets: record.facets, + }) + : undefined, + [record], + ) + const moderation = useMemo( + () => (moderationOpts ? moderatePost(post, moderationOpts) : undefined), + [moderationOpts, post], + ) + if (postShadowed === POST_TOMBSTONE) { + return null } - - // error - // = - if (view.hasError || !view.thread || !view.thread?.postRecord) { - if (hideError) { - return <View /> - } + if (record && richText && moderation) { return ( - <View style={pal.view}> - <Text>{view.error || 'Thread not found'}</Text> - </View> + <PostInner + post={postShadowed} + record={record} + richText={richText} + moderation={moderation} + showReplyLine={showReplyLine} + style={style} + /> ) } + return null +} - // loaded - // = - - return ( - <PostLoaded - item={view.thread} - record={view.thread.postRecord} - setDeleted={setDeleted} - showReplyLine={showReplyLine} - style={style} - /> - ) -}) - -const PostLoaded = observer(function PostLoadedImpl({ - item, +function PostInner({ + post, record, - setDeleted, + richText, + moderation, showReplyLine, style, }: { - item: PostThreadItemModel - record: FeedPost.Record - setDeleted: (v: boolean) => void + post: Shadow<AppBskyFeedDefs.PostView> + record: AppBskyFeedPost.Record + richText: RichTextAPI + moderation: PostModeration showReplyLine?: boolean style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') - const store = useStores() - const [limitLines, setLimitLines] = React.useState( - countLines(item.richText?.text) >= MAX_POST_LINES, + const {openComposer} = useComposerControls() + const [limitLines, setLimitLines] = useState( + () => countLines(richText?.text) >= MAX_POST_LINES, ) - const itemUri = item.post.uri - const itemCid = item.post.cid - const itemUrip = new AtUri(item.post.uri) - const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey) - const itemTitle = `Post by ${item.post.author.handle}` + const itemUrip = new AtUri(post.uri) + const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey) let replyAuthorDid = '' if (record.reply) { const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) replyAuthorDid = urip.hostname } - const translatorUrl = getTranslatorLink( - record?.text || '', - store.preferences.primaryLanguage, - ) - const onPressReply = React.useCallback(() => { - store.shell.openComposer({ + openComposer({ replyTo: { - uri: item.post.uri, - cid: item.post.cid, - text: record.text as string, + uri: post.uri, + cid: post.cid, + text: record.text, author: { - handle: item.post.author.handle, - displayName: item.post.author.displayName, - avatar: item.post.author.avatar, + handle: post.author.handle, + displayName: post.author.displayName, + avatar: post.author.avatar, }, }, }) - }, [store, item, record]) - - const onPressToggleRepost = React.useCallback(() => { - return item - .toggleRepost() - .catch(e => logger.error('Failed to toggle repost', {error: e})) - }, [item]) - - const onPressToggleLike = React.useCallback(() => { - return item - .toggleLike() - .catch(e => logger.error('Failed to toggle like', {error: e})) - }, [item]) - - const onCopyPostText = React.useCallback(() => { - Clipboard.setString(record.text) - Toast.show('Copied to clipboard') - }, [record]) - - const onOpenTranslate = React.useCallback(() => { - Linking.openURL(translatorUrl) - }, [translatorUrl]) - - const onToggleThreadMute = React.useCallback(async () => { - try { - await item.toggleThreadMute() - if (item.isThreadMuted) { - Toast.show('You will no longer receive notifications for this thread') - } else { - Toast.show('You will now receive notifications for this thread') - } - } catch (e) { - logger.error('Failed to toggle thread mute', {error: e}) - } - }, [item]) - - const onDeletePost = React.useCallback(() => { - item.delete().then( - () => { - setDeleted(true) - Toast.show('Post deleted') - }, - e => { - logger.error('Failed to delete post', {error: e}) - Toast.show('Failed to delete post, please try again') - }, - ) - }, [item, setDeleted]) + }, [openComposer, post, record]) const onPressShowMore = React.useCallback(() => { setLimitLines(false) @@ -198,17 +133,17 @@ const PostLoaded = observer(function PostLoadedImpl({ <View style={styles.layoutAvi}> <PreviewableUserAvatar size={52} - did={item.post.author.did} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - moderation={item.moderation.avatar} + did={post.author.did} + handle={post.author.handle} + avatar={post.author.avatar} + moderation={moderation.avatar} /> </View> <View style={styles.layoutContent}> <PostMeta - author={item.post.author} - authorHasWarning={!!item.post.author.labels?.length} - timestamp={item.post.indexedAt} + author={post.author} + authorHasWarning={!!post.author.labels?.length} + timestamp={post.indexedAt} postHref={itemHref} /> {replyAuthorDid !== '' && ( @@ -234,19 +169,16 @@ const PostLoaded = observer(function PostLoadedImpl({ </View> )} <ContentHider - moderation={item.moderation.content} + moderation={moderation.content} style={styles.contentHider} childContainerStyle={styles.contentHiderChild}> - <PostAlerts - moderation={item.moderation.content} - style={styles.alert} - /> - {item.richText?.text ? ( + <PostAlerts moderation={moderation.content} style={styles.alert} /> + {richText.text ? ( <View style={styles.postTextContainer}> <RichText testID="postText" type="post-text" - richText={item.richText} + richText={richText} lineHeight={1.3} numberOfLines={limitLines ? MAX_POST_LINES : undefined} style={s.flex1} @@ -261,45 +193,20 @@ const PostLoaded = observer(function PostLoadedImpl({ href="#" /> ) : undefined} - {item.post.embed ? ( + {post.embed ? ( <ContentHider - moderation={item.moderation.embed} + moderation={moderation.embed} style={styles.contentHider}> - <PostEmbeds - embed={item.post.embed} - moderation={item.moderation.embed} - /> + <PostEmbeds embed={post.embed} moderation={moderation.embed} /> </ContentHider> ) : null} </ContentHider> - <PostCtrls - itemUri={itemUri} - itemCid={itemCid} - itemHref={itemHref} - itemTitle={itemTitle} - author={item.post.author} - indexedAt={item.post.indexedAt} - text={item.richText?.text || record.text} - isAuthor={item.post.author.did === store.me.did} - replyCount={item.post.replyCount} - repostCount={item.post.repostCount} - likeCount={item.post.likeCount} - isReposted={!!item.post.viewer?.repost} - isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} - onPressReply={onPressReply} - onPressToggleRepost={onPressToggleRepost} - onPressToggleLike={onPressToggleLike} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onToggleThreadMute={onToggleThreadMute} - onDeletePost={onDeletePost} - /> + <PostCtrls post={post} record={record} onPressReply={onPressReply} /> </View> </View> </Link> ) -}) +} const styles = StyleSheet.create({ outer: { diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 1ecb14912..f0f7cd919 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -1,7 +1,7 @@ -import React, {MutableRefObject} from 'react' -import {observer} from 'mobx-react-lite' +import React, {memo, MutableRefObject} from 'react' import { ActivityIndicator, + Dimensions, RefreshControl, StyleProp, StyleSheet, @@ -11,26 +11,36 @@ import { import {FlatList} from '../util/Views' import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {FeedErrorMessage} from './FeedErrorMessage' -import {PostsFeedModel} from 'state/models/feeds/posts' import {FeedSlice} from './FeedSlice' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' -import {OnScrollCb} from 'lib/hooks/useOnMainScroll' -import {s} from 'lib/styles' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' +import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useTheme} from 'lib/ThemeContext' import {logger} from '#/logger' +import { + FeedDescriptor, + FeedParams, + usePostFeedQuery, + pollLatest, +} from '#/state/queries/post-feed' +import {useModerationOpts} from '#/state/queries/preferences' 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__'} -export const Feed = observer(function Feed({ +let Feed = ({ feed, + feedParams, style, + enabled, + pollInterval, scrollElRef, onScroll, + onHasNew, scrollEventThrottle, renderEmptyState, renderEndOfFeed, @@ -40,10 +50,14 @@ export const Feed = observer(function Feed({ ListHeaderComponent, extraData, }: { - feed: PostsFeedModel + feed: FeedDescriptor + feedParams?: FeedParams style?: StyleProp<ViewStyle> + enabled?: boolean + pollInterval?: number scrollElRef?: MutableRefObject<FlatList<any> | null> - onScroll?: OnScrollCb + onHasNew?: (v: boolean) => void + onScroll?: OnScrollHandler scrollEventThrottle?: number renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element @@ -52,70 +66,110 @@ export const Feed = observer(function Feed({ desktopFixedHeightOffset?: number ListHeaderComponent?: () => JSX.Element extraData?: any -}) { +}): React.ReactNode => { const pal = usePalette('default') const theme = useTheme() const {track} = useAnalytics() - const [isRefreshing, setIsRefreshing] = React.useState(false) + const [isPTRing, setIsPTRing] = React.useState(false) + const checkForNewRef = React.useRef<(() => void) | null>(null) + + const moderationOpts = useModerationOpts() + const opts = React.useMemo(() => ({enabled}), [enabled]) + const { + data, + isFetching, + isFetched, + isError, + error, + refetch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = usePostFeedQuery(feed, feedParams, opts) + const isEmpty = !isFetching && !data?.pages[0]?.slices.length + + const checkForNew = React.useCallback(async () => { + if (!data?.pages[0] || isFetching || !onHasNew || !enabled) { + return + } + try { + if (await pollLatest(data.pages[0])) { + onHasNew(true) + } + } catch (e) { + logger.error('Poll latest failed', {feed, error: String(e)}) + } + }, [feed, data, isFetching, onHasNew, enabled]) + + React.useEffect(() => { + // we store the interval handler in a ref to avoid needless + // reassignments of the interval + checkForNewRef.current = checkForNew + }, [checkForNew]) + React.useEffect(() => { + if (!pollInterval) { + return + } + const i = setInterval(() => checkForNewRef.current?.(), pollInterval) + return () => clearInterval(i) + }, [pollInterval]) - const data = React.useMemo(() => { - let feedItems: any[] = [] - if (feed.hasLoaded) { - if (feed.hasError) { - feedItems = feedItems.concat([ERROR_ITEM]) + const feedItems = React.useMemo(() => { + let arr: any[] = [] + if (isFetched && moderationOpts) { + if (isError && isEmpty) { + arr = arr.concat([ERROR_ITEM]) } - if (feed.isEmpty) { - feedItems = feedItems.concat([EMPTY_FEED_ITEM]) - } else { - feedItems = feedItems.concat(feed.slices) + if (isEmpty) { + arr = arr.concat([EMPTY_FEED_ITEM]) + } else if (data) { + for (const page of data?.pages) { + arr = arr.concat(page.slices) + } } - if (feed.loadMoreError) { - feedItems = feedItems.concat([LOAD_MORE_ERROR_ITEM]) + if (isError && !isEmpty) { + arr = arr.concat([LOAD_MORE_ERROR_ITEM]) } } else { - feedItems.push(LOADING_ITEM) + arr.push(LOADING_ITEM) } - return feedItems - }, [ - feed.hasError, - feed.hasLoaded, - feed.isEmpty, - feed.slices, - feed.loadMoreError, - ]) + return arr + }, [isFetched, isError, isEmpty, data, moderationOpts]) // events // = const onRefresh = React.useCallback(async () => { track('Feed:onRefresh') - setIsRefreshing(true) + setIsPTRing(true) try { - await feed.refresh() + await refetch() + onHasNew?.(false) } catch (err) { logger.error('Failed to refresh posts feed', {error: err}) } - setIsRefreshing(false) - }, [feed, track, setIsRefreshing]) + setIsPTRing(false) + }, [refetch, track, setIsPTRing, onHasNew]) const onEndReached = React.useCallback(async () => { - if (!feed.hasLoaded || !feed.hasMore) return + if (isFetching || !hasNextPage || isError) return track('Feed:onEndReached') try { - await feed.loadMore() + await fetchNextPage() } catch (err) { logger.error('Failed to load more posts', {error: err}) } - }, [feed, track]) + }, [isFetching, hasNextPage, isError, fetchNextPage, track]) const onPressTryAgain = React.useCallback(() => { - feed.refresh() - }, [feed]) + refetch() + onHasNew?.(false) + }, [refetch, onHasNew]) const onPressRetryLoadMore = React.useCallback(() => { - feed.retryLoadMore() - }, [feed]) + fetchNextPage() + }, [fetchNextPage]) // rendering // = @@ -126,7 +180,11 @@ export const Feed = observer(function Feed({ return renderEmptyState() } else if (item === ERROR_ITEM) { return ( - <FeedErrorMessage feed={feed} onPressTryAgain={onPressTryAgain} /> + <FeedErrorMessage + feedDesc={feed} + error={error} + onPressTryAgain={onPressTryAgain} + /> ) } else if (item === LOAD_MORE_ERROR_ITEM) { return ( @@ -138,47 +196,65 @@ export const Feed = observer(function Feed({ } else if (item === LOADING_ITEM) { return <PostFeedLoadingPlaceholder /> } - return <FeedSlice slice={item} /> + return ( + <FeedSlice + slice={item} + // we check for this before creating the feedItems array + moderationOpts={moderationOpts!} + /> + ) }, - [feed, onPressTryAgain, onPressRetryLoadMore, renderEmptyState], + [ + feed, + error, + onPressTryAgain, + onPressRetryLoadMore, + renderEmptyState, + moderationOpts, + ], ) + const shouldRenderEndOfFeed = + !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed const FeedFooter = React.useCallback( () => - feed.isLoadingMore ? ( + isFetchingNextPage ? ( <View style={styles.feedFooter}> <ActivityIndicator /> </View> - ) : !feed.hasMore && !feed.isEmpty && renderEndOfFeed ? ( + ) : shouldRenderEndOfFeed ? ( renderEndOfFeed() ) : ( <View /> ), - [feed.isLoadingMore, feed.hasMore, feed.isEmpty, renderEndOfFeed], + [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed], ) + const scrollHandler = useAnimatedScrollHandler(onScroll || {}) return ( <View testID={testID} style={style}> <FlatList testID={testID ? `${testID}-flatlist` : undefined} ref={scrollElRef} - data={data} + data={feedItems} keyExtractor={item => item._reactKey} renderItem={renderItem} ListFooterComponent={FeedFooter} ListHeaderComponent={ListHeaderComponent} refreshControl={ <RefreshControl - refreshing={isRefreshing} + refreshing={isPTRing} onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} progressViewOffset={headerOffset} /> } - contentContainerStyle={s.contentContainer} + contentContainerStyle={{ + minHeight: Dimensions.get('window').height * 1.5, + }} style={{paddingTop: headerOffset}} - onScroll={onScroll} + onScroll={onScroll != null ? scrollHandler : undefined} scrollEventThrottle={scrollEventThrottle} indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} onEndReached={onEndReached} @@ -193,7 +269,9 @@ export const Feed = observer(function Feed({ /> </View> ) -}) +} +Feed = memo(Feed) +export {Feed} const styles = StyleSheet.create({ feedFooter: {paddingTop: 20}, diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx index 9e75d9507..63d9d5956 100644 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ b/src/view/com/posts/FeedErrorMessage.tsx @@ -1,7 +1,6 @@ import React from 'react' import {View} from 'react-native' -import {AtUri, AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' -import {PostsFeedModel, KnownError} from 'state/models/feeds/posts' +import {AppBskyFeedGetAuthorFeed, AtUri} from '@atproto/api' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import * as Toast from '../util/Toast' @@ -9,67 +8,118 @@ import {ErrorMessage} from '../util/error/ErrorMessage' import {usePalette} from 'lib/hooks/usePalette' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' -import {useStores} from 'state/index' import {logger} from '#/logger' +import {useModalControls} from '#/state/modals' +import {msg as msgLingui} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {FeedDescriptor} from '#/state/queries/post-feed' +import {EmptyState} from '../util/EmptyState' +import {cleanError} from '#/lib/strings/errors' +import {useRemoveFeedMutation} from '#/state/queries/preferences' -const MESSAGES = { - [KnownError.Unknown]: '', - [KnownError.FeedgenDoesNotExist]: `Hmmm, we're having trouble finding this feed. It may have been deleted.`, - [KnownError.FeedgenMisconfigured]: - 'Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue.', - [KnownError.FeedgenBadResponse]: - 'Hmm, the feed server gave a bad response. Please let the feed owner know about this issue.', - [KnownError.FeedgenOffline]: - 'Hmm, the feed server appears to be offline. Please let the feed owner know about this issue.', - [KnownError.FeedgenUnknown]: - 'Hmm, some kind of issue occured when contacting the feed server. Please let the feed owner know about this issue.', +export enum KnownError { + Block = 'Block', + FeedgenDoesNotExist = 'FeedgenDoesNotExist', + FeedgenMisconfigured = 'FeedgenMisconfigured', + FeedgenBadResponse = 'FeedgenBadResponse', + FeedgenOffline = 'FeedgenOffline', + FeedgenUnknown = 'FeedgenUnknown', + FeedNSFPublic = 'FeedNSFPublic', + Unknown = 'Unknown', } export function FeedErrorMessage({ - feed, + feedDesc, + error, onPressTryAgain, }: { - feed: PostsFeedModel + feedDesc: FeedDescriptor + error: any onPressTryAgain: () => void }) { + const knownError = React.useMemo( + () => detectKnownError(feedDesc, error), + [feedDesc, error], + ) if ( - typeof feed.knownError === 'undefined' || - feed.knownError === KnownError.Unknown + typeof knownError !== 'undefined' && + knownError !== KnownError.Unknown && + feedDesc.startsWith('feedgen') ) { + return <FeedgenErrorMessage feedDesc={feedDesc} knownError={knownError} /> + } + + if (knownError === KnownError.Block) { return ( - <ErrorMessage message={feed.error} onPressTryAgain={onPressTryAgain} /> + <EmptyState + icon="ban" + message="Posts hidden" + style={{paddingVertical: 40}} + /> ) } - return <FeedgenErrorMessage feed={feed} knownError={feed.knownError} /> + return ( + <ErrorMessage + message={cleanError(error)} + onPressTryAgain={onPressTryAgain} + /> + ) } function FeedgenErrorMessage({ - feed, + feedDesc, knownError, }: { - feed: PostsFeedModel + feedDesc: FeedDescriptor knownError: KnownError }) { const pal = usePalette('default') - const store = useStores() + const {_: _l} = useLingui() const navigation = useNavigation<NavigationProp>() - const msg = MESSAGES[knownError] - const uri = (feed.params as GetCustomFeed.QueryParams).feed + const msg = React.useMemo( + () => + ({ + [KnownError.Unknown]: '', + [KnownError.Block]: '', + [KnownError.FeedgenDoesNotExist]: _l( + msgLingui`Hmmm, we're having trouble finding this feed. It may have been deleted.`, + ), + [KnownError.FeedgenMisconfigured]: _l( + msgLingui`Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue.`, + ), + [KnownError.FeedgenBadResponse]: _l( + msgLingui`Hmm, the feed server gave a bad response. Please let the feed owner know about this issue.`, + ), + [KnownError.FeedgenOffline]: _l( + msgLingui`Hmm, the feed server appears to be offline. Please let the feed owner know about this issue.`, + ), + [KnownError.FeedNSFPublic]: _l( + msgLingui`We're sorry, but this content is not viewable without a Bluesky account.`, + ), + [KnownError.FeedgenUnknown]: _l( + msgLingui`Hmm, some kind of issue occured when contacting the feed server. Please let the feed owner know about this issue.`, + ), + }[knownError]), + [_l, knownError], + ) + const [_, uri] = feedDesc.split('|') const [ownerDid] = safeParseFeedgenUri(uri) + const {openModal, closeModal} = useModalControls() + const {mutateAsync: removeFeed} = useRemoveFeedMutation() const onViewProfile = React.useCallback(() => { navigation.navigate('Profile', {name: ownerDid}) }, [navigation, ownerDid]) const onRemoveFeed = React.useCallback(async () => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Remove feed', - message: 'Remove this feed from your saved feeds?', + title: _l(msgLingui`Remove feed`), + message: _l(msgLingui`Remove this feed from your saved feeds?`), async onPressConfirm() { try { - await store.preferences.removeSavedFeed(uri) + await removeFeed({uri}) } catch (err) { Toast.show( 'There was an an issue removing this feed. Please check your internet connection and try again.', @@ -78,10 +128,40 @@ function FeedgenErrorMessage({ } }, onPressCancel() { - store.shell.closeModal() + closeModal() }, }) - }, [store, uri]) + }, [openModal, closeModal, uri, removeFeed, _l]) + + const cta = React.useMemo(() => { + switch (knownError) { + case KnownError.FeedNSFPublic: { + return null + } + case KnownError.FeedgenDoesNotExist: + case KnownError.FeedgenMisconfigured: + case KnownError.FeedgenBadResponse: + case KnownError.FeedgenOffline: + case KnownError.FeedgenUnknown: { + return ( + <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> + {knownError === KnownError.FeedgenDoesNotExist && ( + <Button + type="inverted" + label="Remove feed" + onPress={onRemoveFeed} + /> + )} + <Button + type="default-light" + label="View profile" + onPress={onViewProfile} + /> + </View> + ) + } + } + }, [knownError, onViewProfile, onRemoveFeed]) return ( <View @@ -96,16 +176,7 @@ function FeedgenErrorMessage({ }, ]}> <Text style={pal.text}>{msg}</Text> - <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> - {knownError === KnownError.FeedgenDoesNotExist && ( - <Button type="inverted" label="Remove feed" onPress={onRemoveFeed} /> - )} - <Button - type="default-light" - label="View profile" - onPress={onViewProfile} - /> - </View> + {cta} </View> ) } @@ -118,3 +189,48 @@ function safeParseFeedgenUri(uri: string): [string, string] { return ['', ''] } } + +function detectKnownError( + feedDesc: FeedDescriptor, + error: any, +): KnownError | undefined { + if (!error) { + return undefined + } + if ( + error instanceof AppBskyFeedGetAuthorFeed.BlockedActorError || + error instanceof AppBskyFeedGetAuthorFeed.BlockedByActorError + ) { + return KnownError.Block + } + if (typeof error !== 'string') { + error = error.toString() + } + if (!feedDesc.startsWith('feedgen')) { + return KnownError.Unknown + } + if (error.includes('could not find feed')) { + return KnownError.FeedgenDoesNotExist + } + if (error.includes('feed unavailable')) { + return KnownError.FeedgenOffline + } + if (error.includes('invalid did document')) { + return KnownError.FeedgenMisconfigured + } + if (error.includes('could not resolve did document')) { + return KnownError.FeedgenMisconfigured + } + if ( + error.includes('invalid feed generator service details in did document') + ) { + return KnownError.FeedgenMisconfigured + } + if (error.includes('feed provided an invalid response')) { + return KnownError.FeedgenBadResponse + } + if (error.includes(KnownError.FeedNSFPublic)) { + return KnownError.FeedNSFPublic + } + return KnownError.FeedgenUnknown +} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index aeee3e20a..dfb0cfcf6 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -1,14 +1,17 @@ -import React, {useMemo, useState} from 'react' -import {observer} from 'mobx-react-lite' -import {Linking, StyleSheet, View} from 'react-native' -import Clipboard from '@react-native-clipboard/clipboard' -import {AtUri} from '@atproto/api' +import React, {memo, useMemo, useState} from 'react' +import {StyleSheet, View} from 'react-native' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + AtUri, + PostModeration, + RichText as RichTextAPI, +} from '@atproto/api' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {PostsFeedItemModel} from 'state/models/feeds/post' -import {FeedSourceInfo} from 'lib/api/feed/types' +import {ReasonFeedSource, isReasonFeedSource} from 'lib/api/feed/types' import {Link, TextLinkOnWebOnly, TextLink} from '../util/Link' import {Text} from '../util/text/Text' import {UserInfoText} from '../util/UserInfoText' @@ -19,50 +22,96 @@ import {ContentHider} from '../util/moderation/ContentHider' import {PostAlerts} from '../util/moderation/PostAlerts' import {RichText} from '../util/text/RichText' import {PostSandboxWarning} from '../util/PostSandboxWarning' -import * as Toast from '../util/Toast' import {PreviewableUserAvatar} from '../util/UserAvatar' import {s} from 'lib/styles' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' -import {getTranslatorLink} from '../../../locale/helpers' import {makeProfileLink} from 'lib/routes/links' import {isEmbedByEmbedder} from 'lib/embeds' import {MAX_POST_LINES} from 'lib/constants' import {countLines} from 'lib/strings/helpers' -import {logger} from '#/logger' +import {useComposerControls} from '#/state/shell/composer' +import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' -export const FeedItem = observer(function FeedItemImpl({ - item, - source, +export function FeedItem({ + post, + record, + reason, + moderation, isThreadChild, isThreadLastChild, isThreadParent, }: { - item: PostsFeedItemModel - source?: FeedSourceInfo + post: AppBskyFeedDefs.PostView + record: AppBskyFeedPost.Record + reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined + moderation: PostModeration isThreadChild?: boolean isThreadLastChild?: boolean isThreadParent?: boolean - showReplyLine?: boolean }) { - const store = useStores() + const postShadowed = usePostShadow(post) + const richText = useMemo( + () => + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], + ) + if (postShadowed === POST_TOMBSTONE) { + return null + } + if (richText && moderation) { + return ( + <FeedItemInner + post={postShadowed} + record={record} + reason={reason} + richText={richText} + moderation={moderation} + isThreadChild={isThreadChild} + isThreadLastChild={isThreadLastChild} + isThreadParent={isThreadParent} + /> + ) + } + return null +} + +let FeedItemInner = ({ + post, + record, + reason, + richText, + moderation, + isThreadChild, + isThreadLastChild, + isThreadParent, +}: { + post: Shadow<AppBskyFeedDefs.PostView> + record: AppBskyFeedPost.Record + reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined + richText: RichTextAPI + moderation: PostModeration + isThreadChild?: boolean + isThreadLastChild?: boolean + isThreadParent?: boolean +}): React.ReactNode => { + const {openComposer} = useComposerControls() const pal = usePalette('default') const {track} = useAnalytics() - const [deleted, setDeleted] = useState(false) const [limitLines, setLimitLines] = useState( - countLines(item.richText?.text) >= MAX_POST_LINES, + () => countLines(richText.text) >= MAX_POST_LINES, ) - const record = item.postRecord - const itemUri = item.post.uri - const itemCid = item.post.cid - const itemHref = useMemo(() => { - const urip = new AtUri(item.post.uri) - return makeProfileLink(item.post.author, 'post', urip.rkey) - }, [item.post.uri, item.post.author]) - const itemTitle = `Post by ${item.post.author.handle}` + + const href = useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey) + }, [post.uri, post.author]) + const replyAuthorDid = useMemo(() => { if (!record?.reply) { return '' @@ -70,77 +119,22 @@ export const FeedItem = observer(function FeedItemImpl({ const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) return urip.hostname }, [record?.reply]) - const translatorUrl = getTranslatorLink( - record?.text || '', - store.preferences.primaryLanguage, - ) const onPressReply = React.useCallback(() => { track('FeedItem:PostReply') - store.shell.openComposer({ + openComposer({ replyTo: { - uri: item.post.uri, - cid: item.post.cid, - text: record?.text || '', + uri: post.uri, + cid: post.cid, + text: record.text || '', author: { - handle: item.post.author.handle, - displayName: item.post.author.displayName, - avatar: item.post.author.avatar, + handle: post.author.handle, + displayName: post.author.displayName, + avatar: post.author.avatar, }, }, }) - }, [item, track, record, store]) - - const onPressToggleRepost = React.useCallback(() => { - track('FeedItem:PostRepost') - return item - .toggleRepost() - .catch(e => logger.error('Failed to toggle repost', {error: e})) - }, [track, item]) - - const onPressToggleLike = React.useCallback(() => { - track('FeedItem:PostLike') - return item - .toggleLike() - .catch(e => logger.error('Failed to toggle like', {error: e})) - }, [track, item]) - - const onCopyPostText = React.useCallback(() => { - Clipboard.setString(record?.text || '') - Toast.show('Copied to clipboard') - }, [record]) - - const onOpenTranslate = React.useCallback(() => { - Linking.openURL(translatorUrl) - }, [translatorUrl]) - - const onToggleThreadMute = React.useCallback(async () => { - track('FeedItem:ThreadMute') - try { - await item.toggleThreadMute() - if (item.isThreadMuted) { - Toast.show('You will no longer receive notifications for this thread') - } else { - Toast.show('You will now receive notifications for this thread') - } - } catch (e) { - logger.error('Failed to toggle thread mute', {error: e}) - } - }, [track, item]) - - const onDeletePost = React.useCallback(() => { - track('FeedItem:PostDelete') - item.delete().then( - () => { - setDeleted(true) - Toast.show('Post deleted') - }, - e => { - logger.error('Failed to delete post', {error: e}) - Toast.show('Failed to delete post, please try again') - }, - ) - }, [track, item, setDeleted]) + }, [post, record, track, openComposer]) const onPressShowMore = React.useCallback(() => { setLimitLines(false) @@ -159,15 +153,11 @@ export const FeedItem = observer(function FeedItemImpl({ isThreadChild ? styles.outerSmallTop : undefined, ] - if (!record || deleted) { - return <View /> - } - return ( <Link - testID={`feedItem-by-${item.post.author.handle}`} + testID={`feedItem-by-${post.author.handle}`} style={outerStyles} - href={itemHref} + href={href} noFeedback accessible={false}> <PostSandboxWarning /> @@ -189,10 +179,10 @@ export const FeedItem = observer(function FeedItemImpl({ </View> <View style={{paddingTop: 12, flexShrink: 1}}> - {source ? ( + {isReasonFeedSource(reason) ? ( <Link - title={sanitizeDisplayName(source.displayName)} - href={source.uri}> + title={sanitizeDisplayName(reason.displayName)} + href={reason.uri}> <Text type="sm-bold" style={pal.textLight} @@ -204,17 +194,17 @@ export const FeedItem = observer(function FeedItemImpl({ style={pal.textLight} lineHeight={1.2} numberOfLines={1} - text={sanitizeDisplayName(source.displayName)} - href={source.uri} + text={sanitizeDisplayName(reason.displayName)} + href={reason.uri} /> </Text> </Link> - ) : item.reasonRepost ? ( + ) : AppBskyFeedDefs.isReasonRepost(reason) ? ( <Link style={styles.includeReason} - href={makeProfileLink(item.reasonRepost.by)} + href={makeProfileLink(reason.by)} title={`Reposted by ${sanitizeDisplayName( - item.reasonRepost.by.displayName || item.reasonRepost.by.handle, + reason.by.displayName || reason.by.handle, )}`}> <FontAwesomeIcon icon="retweet" @@ -236,10 +226,9 @@ export const FeedItem = observer(function FeedItemImpl({ lineHeight={1.2} numberOfLines={1} text={sanitizeDisplayName( - item.reasonRepost.by.displayName || - sanitizeHandle(item.reasonRepost.by.handle), + reason.by.displayName || sanitizeHandle(reason.by.handle), )} - href={makeProfileLink(item.reasonRepost.by)} + href={makeProfileLink(reason.by)} /> </Text> </Link> @@ -251,10 +240,10 @@ export const FeedItem = observer(function FeedItemImpl({ <View style={styles.layoutAvi}> <PreviewableUserAvatar size={52} - did={item.post.author.did} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - moderation={item.moderation.avatar} + did={post.author.did} + handle={post.author.handle} + avatar={post.author.avatar} + moderation={moderation.avatar} /> {isThreadParent && ( <View @@ -271,10 +260,10 @@ export const FeedItem = observer(function FeedItemImpl({ </View> <View style={styles.layoutContent}> <PostMeta - author={item.post.author} - authorHasWarning={!!item.post.author.labels?.length} - timestamp={item.post.indexedAt} - postHref={itemHref} + author={post.author} + authorHasWarning={!!post.author.labels?.length} + timestamp={post.indexedAt} + postHref={href} /> {!isThreadChild && replyAuthorDid !== '' && ( <View style={[s.flexRow, s.mb2, s.alignCenter]}> @@ -303,19 +292,16 @@ export const FeedItem = observer(function FeedItemImpl({ )} <ContentHider testID="contentHider-post" - moderation={item.moderation.content} + moderation={moderation.content} ignoreMute childContainerStyle={styles.contentHiderChild}> - <PostAlerts - moderation={item.moderation.content} - style={styles.alert} - /> - {item.richText?.text ? ( + <PostAlerts moderation={moderation.content} style={styles.alert} /> + {richText.text ? ( <View style={styles.postTextContainer}> <RichText testID="postText" type="post-text" - richText={item.richText} + richText={richText} lineHeight={1.3} numberOfLines={limitLines ? MAX_POST_LINES : undefined} style={s.flex1} @@ -330,50 +316,23 @@ export const FeedItem = observer(function FeedItemImpl({ href="#" /> ) : undefined} - {item.post.embed ? ( + {post.embed ? ( <ContentHider testID="contentHider-embed" - moderation={item.moderation.embed} - ignoreMute={isEmbedByEmbedder( - item.post.embed, - item.post.author.did, - )} + moderation={moderation.embed} + ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)} style={styles.embed}> - <PostEmbeds - embed={item.post.embed} - moderation={item.moderation.embed} - /> + <PostEmbeds embed={post.embed} moderation={moderation.embed} /> </ContentHider> ) : null} </ContentHider> - <PostCtrls - itemUri={itemUri} - itemCid={itemCid} - itemHref={itemHref} - itemTitle={itemTitle} - author={item.post.author} - text={item.richText?.text || record.text} - indexedAt={item.post.indexedAt} - isAuthor={item.post.author.did === store.me.did} - replyCount={item.post.replyCount} - repostCount={item.post.repostCount} - likeCount={item.post.likeCount} - isReposted={!!item.post.viewer?.repost} - isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} - onPressReply={onPressReply} - onPressToggleRepost={onPressToggleRepost} - onPressToggleLike={onPressToggleLike} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onToggleThreadMute={onToggleThreadMute} - onDeletePost={onDeletePost} - /> + <PostCtrls post={post} record={record} onPressReply={onPressReply} /> </View> </View> </Link> ) -}) +} +FeedItemInner = memo(FeedItemInner) const styles = StyleSheet.create({ outer: { diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index 1d26f6cbd..a3bacdc1e 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -1,8 +1,7 @@ -import React from 'react' +import React, {memo} from 'react' import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice' -import {AtUri} from '@atproto/api' +import {FeedPostSlice} from '#/state/queries/post-feed' +import {AtUri, moderatePost, ModerationOpts} from '@atproto/api' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import Svg, {Circle, Line} from 'react-native-svg' @@ -10,15 +9,27 @@ import {FeedItem} from './FeedItem' import {usePalette} from 'lib/hooks/usePalette' import {makeProfileLink} from 'lib/routes/links' -export const FeedSlice = observer(function FeedSliceImpl({ +let FeedSlice = ({ slice, ignoreFilterFor, + moderationOpts, }: { - slice: PostsFeedSliceModel + slice: FeedPostSlice ignoreFilterFor?: string -}) { - if (slice.shouldFilter(ignoreFilterFor)) { - return null + moderationOpts: ModerationOpts +}): React.ReactNode => { + const moderations = React.useMemo(() => { + return slice.items.map(item => moderatePost(item.post, moderationOpts)) + }, [slice, moderationOpts]) + + // apply moderation filter + for (let i = 0; i < slice.items.length; i++) { + if ( + moderations[i]?.content.filter && + slice.items[i].post.author.did !== ignoreFilterFor + ) { + return null + } } if (slice.isThread && slice.items.length > 3) { @@ -27,23 +38,31 @@ export const FeedSlice = observer(function FeedSliceImpl({ <> <FeedItem key={slice.items[0]._reactKey} - item={slice.items[0]} - source={slice.source} - isThreadParent={slice.isThreadParentAt(0)} - isThreadChild={slice.isThreadChildAt(0)} + post={slice.items[0].post} + record={slice.items[0].record} + reason={slice.items[0].reason} + moderation={moderations[0]} + isThreadParent={isThreadParentAt(slice.items, 0)} + isThreadChild={isThreadChildAt(slice.items, 0)} /> <FeedItem key={slice.items[1]._reactKey} - item={slice.items[1]} - isThreadParent={slice.isThreadParentAt(1)} - isThreadChild={slice.isThreadChildAt(1)} + post={slice.items[1].post} + record={slice.items[1].record} + reason={slice.items[1].reason} + moderation={moderations[1]} + isThreadParent={isThreadParentAt(slice.items, 1)} + isThreadChild={isThreadChildAt(slice.items, 1)} /> <ViewFullThread slice={slice} /> <FeedItem key={slice.items[last]._reactKey} - item={slice.items[last]} - isThreadParent={slice.isThreadParentAt(last)} - isThreadChild={slice.isThreadChildAt(last)} + post={slice.items[last].post} + record={slice.items[last].record} + reason={slice.items[last].reason} + moderation={moderations[last]} + isThreadParent={isThreadParentAt(slice.items, last)} + isThreadChild={isThreadChildAt(slice.items, last)} isThreadLastChild /> </> @@ -55,25 +74,29 @@ export const FeedSlice = observer(function FeedSliceImpl({ {slice.items.map((item, i) => ( <FeedItem key={item._reactKey} - item={item} - source={i === 0 ? slice.source : undefined} - isThreadParent={slice.isThreadParentAt(i)} - isThreadChild={slice.isThreadChildAt(i)} + post={slice.items[i].post} + record={slice.items[i].record} + reason={slice.items[i].reason} + moderation={moderations[i]} + isThreadParent={isThreadParentAt(slice.items, i)} + isThreadChild={isThreadChildAt(slice.items, i)} isThreadLastChild={ - slice.isThreadChildAt(i) && slice.items.length === i + 1 + isThreadChildAt(slice.items, i) && slice.items.length === i + 1 } /> ))} </> ) -}) +} +FeedSlice = memo(FeedSlice) +export {FeedSlice} -function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) { +function ViewFullThread({slice}: {slice: FeedPostSlice}) { const pal = usePalette('default') const itemHref = React.useMemo(() => { - const urip = new AtUri(slice.rootItem.post.uri) - return makeProfileLink(slice.rootItem.post.author, 'post', urip.rkey) - }, [slice.rootItem.post.uri, slice.rootItem.post.author]) + const urip = new AtUri(slice.rootUri) + return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey) + }, [slice.rootUri]) return ( <Link @@ -115,3 +138,17 @@ const styles = StyleSheet.create({ alignItems: 'center', }, }) + +function isThreadParentAt<T>(arr: Array<T>, i: number) { + if (arr.length === 1) { + return false + } + return i < arr.length - 1 +} + +function isThreadChildAt<T>(arr: Array<T>, i: number) { + if (arr.length === 1) { + return false + } + return i > 0 +} diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index adb496f6d..1252f8ca8 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -1,47 +1,65 @@ import React from 'react' import {StyleProp, TextStyle, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {AppBskyActorDefs} from '@atproto/api' import {Button, ButtonType} from '../util/forms/Button' import * as Toast from '../util/Toast' -import {FollowState} from 'state/models/cache/my-follows' -import {useFollowProfile} from 'lib/hooks/useFollowProfile' +import {useProfileFollowMutationQueue} from '#/state/queries/profile' +import {Shadow} from '#/state/cache/types' -export const FollowButton = observer(function FollowButtonImpl({ +export function FollowButton({ unfollowedType = 'inverted', followedType = 'default', profile, - onToggleFollow, labelStyle, }: { unfollowedType?: ButtonType followedType?: ButtonType - profile: AppBskyActorDefs.ProfileViewBasic - onToggleFollow?: (v: boolean) => void + profile: Shadow<AppBskyActorDefs.ProfileViewBasic> labelStyle?: StyleProp<TextStyle> }) { - const {state, following, toggle} = useFollowProfile(profile) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) - const onPress = React.useCallback(async () => { + const onPressFollow = async () => { try { - const {following} = await toggle() - onToggleFollow?.(following) + await queueFollow() } catch (e: any) { - Toast.show('An issue occurred, please try again.') + if (e?.name !== 'AbortError') { + Toast.show(`An issue occurred, please try again.`) + } } - }, [toggle, onToggleFollow]) + } - if (state === FollowState.Unknown) { + const onPressUnfollow = async () => { + try { + await queueUnfollow() + } catch (e: any) { + if (e?.name !== 'AbortError') { + Toast.show(`An issue occurred, please try again.`) + } + } + } + + if (!profile.viewer) { return <View /> } - return ( - <Button - type={following ? followedType : unfollowedType} - labelStyle={labelStyle} - onPress={onPress} - label={following ? 'Unfollow' : 'Follow'} - withLoading={true} - /> - ) -}) + if (profile.viewer.following) { + return ( + <Button + type={followedType} + labelStyle={labelStyle} + onPress={onPressUnfollow} + label="Unfollow" + /> + ) + } else { + return ( + <Button + type={unfollowedType} + labelStyle={labelStyle} + onPress={onPressFollow} + label="Follow" + /> + ) + } +} diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 60dda6798..b14f2833b 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -1,6 +1,5 @@ import * as React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {observer} from 'mobx-react-lite' import { AppBskyActorDefs, moderateProfile, @@ -11,7 +10,6 @@ import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {FollowButton} from './FollowButton' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' @@ -21,10 +19,14 @@ import { getProfileModerationCauses, getModerationCauseKey, } from 'lib/moderation' +import {Shadow} from '#/state/cache/types' +import {useModerationOpts} from '#/state/queries/preferences' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useSession} from '#/state/session' -export const ProfileCard = observer(function ProfileCardImpl({ +export function ProfileCard({ testID, - profile, + profile: profileUnshadowed, noBg, noBorder, followers, @@ -36,13 +38,18 @@ export const ProfileCard = observer(function ProfileCardImpl({ noBg?: boolean noBorder?: boolean followers?: AppBskyActorDefs.ProfileView[] | undefined - renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => React.ReactNode + renderButton?: ( + profile: Shadow<AppBskyActorDefs.ProfileViewBasic>, + ) => React.ReactNode style?: StyleProp<ViewStyle> }) { - const store = useStores() const pal = usePalette('default') - - const moderation = moderateProfile(profile, store.preferences.moderationOpts) + const profile = useProfileShadow(profileUnshadowed) + const moderationOpts = useModerationOpts() + if (!moderationOpts) { + return null + } + const moderation = moderateProfile(profile, moderationOpts) return ( <Link @@ -100,7 +107,7 @@ export const ProfileCard = observer(function ProfileCardImpl({ <FollowersList followers={followers} /> </Link> ) -}) +} function ProfileCardPills({ followedBy, @@ -142,24 +149,31 @@ function ProfileCardPills({ ) } -const FollowersList = observer(function FollowersListImpl({ +function FollowersList({ followers, }: { followers?: AppBskyActorDefs.ProfileView[] | undefined }) { - const store = useStores() const pal = usePalette('default') - if (!followers?.length) { + const moderationOpts = useModerationOpts() + + const followersWithMods = React.useMemo(() => { + if (!followers || !moderationOpts) { + return [] + } + + return followers + .map(f => ({ + f, + mod: moderateProfile(f, moderationOpts), + })) + .filter(({mod}) => !mod.account.filter) + }, [followers, moderationOpts]) + + if (!followersWithMods?.length) { return null } - const followersWithMods = followers - .map(f => ({ - f, - mod: moderateProfile(f, store.preferences.moderationOpts), - })) - .filter(({mod}) => !mod.account.filter) - return ( <View style={styles.followedBy}> <Text @@ -179,36 +193,36 @@ const FollowersList = observer(function FollowersListImpl({ ))} </View> ) -}) +} -export const ProfileCardWithFollowBtn = observer( - function ProfileCardWithFollowBtnImpl({ - profile, - noBg, - noBorder, - followers, - }: { - profile: AppBskyActorDefs.ProfileViewBasic - noBg?: boolean - noBorder?: boolean - followers?: AppBskyActorDefs.ProfileView[] | undefined - }) { - const store = useStores() - const isMe = store.me.did === profile.did +export function ProfileCardWithFollowBtn({ + profile, + noBg, + noBorder, + followers, +}: { + profile: AppBskyActorDefs.ProfileViewBasic + noBg?: boolean + noBorder?: boolean + followers?: AppBskyActorDefs.ProfileView[] | undefined +}) { + const {currentAccount} = useSession() + const isMe = profile.did === currentAccount?.did - return ( - <ProfileCard - profile={profile} - noBg={noBg} - noBorder={noBorder} - followers={followers} - renderButton={ - isMe ? undefined : () => <FollowButton profile={profile} /> - } - /> - ) - }, -) + return ( + <ProfileCard + profile={profile} + noBg={noBg} + noBorder={noBorder} + followers={followers} + renderButton={ + isMe + ? undefined + : profileShadow => <FollowButton profile={profileShadow} /> + } + /> + ) +} const styles = StyleSheet.create({ outer: { diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index 00ea48ed6..d94f5103e 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -1,49 +1,68 @@ -import React, {useEffect} from 'react' -import {observer} from 'mobx-react-lite' +import React from 'react' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' -import { - UserFollowersModel, - FollowerItem, -} from 'state/models/lists/user-followers' +import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {CenteredView, FlatList} from '../util/Views' import {ErrorMessage} from '../util/error/ErrorMessage' import {ProfileCardWithFollowBtn} from './ProfileCard' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' +import {useProfileFollowersQuery} from '#/state/queries/profile-followers' +import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {logger} from '#/logger' +import {cleanError} from '#/lib/strings/errors' -export const ProfileFollowers = observer(function ProfileFollowers({ - name, -}: { - name: string -}) { +export function ProfileFollowers({name}: {name: string}) { const pal = usePalette('default') - const store = useStores() - const view = React.useMemo( - () => new UserFollowersModel(store, {actor: name}), - [store, name], - ) + const [isPTRing, setIsPTRing] = React.useState(false) + const { + data: resolvedDid, + error: resolveError, + isFetching: isFetchingDid, + } = useResolveDidQuery(name) + const { + data, + isFetching, + isFetched, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = useProfileFollowersQuery(resolvedDid) - useEffect(() => { - view - .loadMore() - .catch(err => - logger.error('Failed to fetch user followers', {error: err}), - ) - }, [view]) + const followers = React.useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.followers) + } + }, [data]) - const onRefresh = () => { - view.refresh() - } - const onEndReached = () => { - view.loadMore().catch(err => - logger.error('Failed to load more followers', { - error: err, - }), - ) + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh followers', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) + + const onEndReached = async () => { + if (isFetching || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more followers', {error: err}) + } } - if (!view.hasLoaded) { + const renderItem = React.useCallback( + ({item}: {item: ActorDefs.ProfileViewBasic}) => ( + <ProfileCardWithFollowBtn key={item.did} profile={item} /> + ), + [], + ) + + if (isFetchingDid || !isFetched) { return ( <CenteredView> <ActivityIndicator /> @@ -53,26 +72,26 @@ export const ProfileFollowers = observer(function ProfileFollowers({ // error // = - if (view.hasError) { + if (resolveError || isError) { return ( <CenteredView> - <ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> + <ErrorMessage + message={cleanError(resolveError || error)} + onPressTryAgain={onRefresh} + /> </CenteredView> ) } // loaded // = - const renderItem = ({item}: {item: FollowerItem}) => ( - <ProfileCardWithFollowBtn key={item.did} profile={item} /> - ) return ( <FlatList - data={view.followers} + data={followers} keyExtractor={item => item.did} refreshControl={ <RefreshControl - refreshing={view.isRefreshing} + refreshing={isPTRing} onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} @@ -85,15 +104,14 @@ export const ProfileFollowers = observer(function ProfileFollowers({ // eslint-disable-next-line react/no-unstable-nested-components ListFooterComponent={() => ( <View style={styles.footer}> - {view.isLoading && <ActivityIndicator />} + {(isFetching || isFetchingNextPage) && <ActivityIndicator />} </View> )} - extraData={view.isLoading} // @ts-ignore our .web version only -prf desktopFixedHeight /> ) -}) +} const styles = StyleSheet.create({ footer: { diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index abc35398a..890c13eb2 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -1,42 +1,68 @@ -import React, {useEffect} from 'react' -import {observer} from 'mobx-react-lite' +import React from 'react' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' +import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {CenteredView, FlatList} from '../util/Views' -import {UserFollowsModel, FollowItem} from 'state/models/lists/user-follows' import {ErrorMessage} from '../util/error/ErrorMessage' import {ProfileCardWithFollowBtn} from './ProfileCard' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' +import {useProfileFollowsQuery} from '#/state/queries/profile-follows' +import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {logger} from '#/logger' +import {cleanError} from '#/lib/strings/errors' -export const ProfileFollows = observer(function ProfileFollows({ - name, -}: { - name: string -}) { +export function ProfileFollows({name}: {name: string}) { const pal = usePalette('default') - const store = useStores() - const view = React.useMemo( - () => new UserFollowsModel(store, {actor: name}), - [store, name], - ) + const [isPTRing, setIsPTRing] = React.useState(false) + const { + data: resolvedDid, + error: resolveError, + isFetching: isFetchingDid, + } = useResolveDidQuery(name) + const { + data, + isFetching, + isFetched, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = useProfileFollowsQuery(resolvedDid) - useEffect(() => { - view - .loadMore() - .catch(err => logger.error('Failed to fetch user follows', err)) - }, [view]) + const follows = React.useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.follows) + } + }, [data]) - const onRefresh = () => { - view.refresh() - } - const onEndReached = () => { - view - .loadMore() - .catch(err => logger.error('Failed to load more follows', err)) + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh follows', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) + + const onEndReached = async () => { + if (isFetching || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more follows', {error: err}) + } } - if (!view.hasLoaded) { + const renderItem = React.useCallback( + ({item}: {item: ActorDefs.ProfileViewBasic}) => ( + <ProfileCardWithFollowBtn key={item.did} profile={item} /> + ), + [], + ) + + if (isFetchingDid || !isFetched) { return ( <CenteredView> <ActivityIndicator /> @@ -46,26 +72,26 @@ export const ProfileFollows = observer(function ProfileFollows({ // error // = - if (view.hasError) { + if (resolveError || isError) { return ( <CenteredView> - <ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> + <ErrorMessage + message={cleanError(resolveError || error)} + onPressTryAgain={onRefresh} + /> </CenteredView> ) } // loaded // = - const renderItem = ({item}: {item: FollowItem}) => ( - <ProfileCardWithFollowBtn key={item.did} profile={item} /> - ) return ( <FlatList - data={view.follows} + data={follows} keyExtractor={item => item.did} refreshControl={ <RefreshControl - refreshing={view.isRefreshing} + refreshing={isPTRing} onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} @@ -78,15 +104,14 @@ export const ProfileFollows = observer(function ProfileFollows({ // eslint-disable-next-line react/no-unstable-nested-components ListFooterComponent={() => ( <View style={styles.footer}> - {view.isLoading && <ActivityIndicator />} + {(isFetching || isFetchingNextPage) && <ActivityIndicator />} </View> )} - extraData={view.isLoading} // @ts-ignore our .web version only -prf desktopFixedHeight /> ) -}) +} const styles = StyleSheet.create({ footer: { diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 1a1d38e4b..8058551c2 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -1,5 +1,4 @@ -import React from 'react' -import {observer} from 'mobx-react-lite' +import React, {memo} from 'react' import { StyleSheet, TouchableOpacity, @@ -8,15 +7,17 @@ import { } from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' +import { + AppBskyActorDefs, + ProfileModeration, + RichText as RichTextAPI, +} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {NavigationProp} from 'lib/routes/types' +import {isNative, isWeb} from 'platform/detection' import {BlurView} from '../util/BlurView' -import {ProfileModel} from 'state/models/content/profile' -import {useStores} from 'state/index' -import {ProfileImageLightbox} from 'state/models/ui/shell' -import {pluralize} from 'lib/strings/helpers' -import {toShareUrl} from 'lib/strings/url-helpers' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' -import {s, colors} from 'lib/styles' import * as Toast from '../util/Toast' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {Text} from '../util/text/Text' @@ -25,32 +26,45 @@ import {RichText} from '../util/text/RichText' import {UserAvatar} from '../util/UserAvatar' import {UserBanner} from '../util/UserBanner' import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts' +import {formatCount} from '../util/numeric/format' +import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown' +import {Link} from '../util/Link' +import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' +import {useModalControls} from '#/state/modals' +import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox' +import { + RQKEY as profileQueryKey, + useProfileMuteMutationQueue, + useProfileBlockMutationQueue, + useProfileFollowMutationQueue, +} from '#/state/queries/profile' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {NavigationProp} from 'lib/routes/types' -import {isNative} from 'platform/detection' -import {FollowState} from 'state/models/cache/my-follows' -import {shareUrl} from 'lib/sharing' -import {formatCount} from '../util/numeric/format' -import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown' import {BACK_HITSLOP} from 'lib/constants' import {isInvalidHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' -import {Link} from '../util/Link' -import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' +import {pluralize} from 'lib/strings/helpers' +import {toShareUrl} from 'lib/strings/url-helpers' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {shareUrl} from 'lib/sharing' +import {s, colors} from 'lib/styles' import {logger} from '#/logger' +import {useSession} from '#/state/session' +import {Shadow} from '#/state/cache/types' +import {useRequireAuth} from '#/state/session' interface Props { - view: ProfileModel - onRefreshAll: () => void + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> | null + moderation: ProfileModeration | null hideBackButton?: boolean isProfilePreview?: boolean } -export const ProfileHeader = observer(function ProfileHeaderImpl({ - view, - onRefreshAll, +export function ProfileHeader({ + profile, + moderation, hideBackButton = false, isProfilePreview, }: Props) { @@ -58,7 +72,7 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({ // loading // = - if (!view || !view.hasLoaded) { + if (!profile || !moderation) { return ( <View style={pal.view}> <LoadingPlaceholder width="100%" height={153} /> @@ -70,54 +84,65 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({ <View style={[styles.buttonsLine]}> <LoadingPlaceholder width={167} height={31} style={styles.br50} /> </View> - <View> - <Text type="title-2xl" style={[pal.text, styles.title]}> - {sanitizeDisplayName( - view.displayName || sanitizeHandle(view.handle), - )} - </Text> - </View> </View> </View> ) } - // error - // = - if (view.hasError) { - return ( - <View testID="profileHeaderHasError"> - <Text>{view.error}</Text> - </View> - ) - } - // loaded // = return ( <ProfileHeaderLoaded - view={view} - onRefreshAll={onRefreshAll} + profile={profile} + moderation={moderation} hideBackButton={hideBackButton} isProfilePreview={isProfilePreview} /> ) -}) +} + +interface LoadedProps { + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> + moderation: ProfileModeration + hideBackButton?: boolean + isProfilePreview?: boolean +} -const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ - view, - onRefreshAll, +let ProfileHeaderLoaded = ({ + profile, + moderation, hideBackButton = false, isProfilePreview, -}: Props) { +}: LoadedProps): React.ReactNode => { const pal = usePalette('default') const palInverted = usePalette('inverted') - const store = useStores() + const {currentAccount, hasSession} = useSession() + const requireAuth = useRequireAuth() + const {_} = useLingui() + const {openModal} = useModalControls() + const {openLightbox} = useLightboxControls() const navigation = useNavigation<NavigationProp>() const {track} = useAnalytics() - const invalidHandle = isInvalidHandle(view.handle) + const invalidHandle = isInvalidHandle(profile.handle) const {isDesktop} = useWebMediaQueries() const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) + const descriptionRT = React.useMemo( + () => + profile.description + ? new RichTextAPI({text: profile.description}) + : undefined, + [profile], + ) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) + const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) + const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) + const queryClient = useQueryClient() + + const invalidateProfileQuery = React.useCallback(() => { + queryClient.invalidateQueries({ + queryKey: profileQueryKey(profile.did), + }) + }, [queryClient, profile.did]) const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { @@ -129,144 +154,162 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ const onPressAvi = React.useCallback(() => { if ( - view.avatar && - !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) + profile.avatar && + !(moderation.avatar.blur && moderation.avatar.noOverride) ) { - store.shell.openLightbox(new ProfileImageLightbox(view)) + openLightbox(new ProfileImageLightbox(profile)) } - }, [store, view]) + }, [openLightbox, profile, moderation]) - const onPressToggleFollow = React.useCallback(() => { - view?.toggleFollowing().then( - () => { - setShowSuggestedFollows(Boolean(view.viewer.following)) + const onPressFollow = () => { + requireAuth(async () => { + try { + track('ProfileHeader:FollowButtonClicked') + await queueFollow() Toast.show( - `${ - view.viewer.following ? 'Following' : 'No longer following' - } ${sanitizeDisplayName(view.displayName || view.handle)}`, + `Following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, ) - track( - view.viewer.following - ? 'ProfileHeader:FollowButtonClicked' - : 'ProfileHeader:UnfollowButtonClicked', + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to follow', {error: String(e)}) + Toast.show(`There was an issue! ${e.toString()}`) + } + } + }) + } + + const onPressUnfollow = () => { + requireAuth(async () => { + try { + track('ProfileHeader:UnfollowButtonClicked') + await queueUnfollow() + Toast.show( + `No longer following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, ) - }, - err => logger.error('Failed to toggle follow', {error: err}), - ) - }, [track, view, setShowSuggestedFollows]) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to unfollow', {error: String(e)}) + Toast.show(`There was an issue! ${e.toString()}`) + } + } + }) + } const onPressEditProfile = React.useCallback(() => { track('ProfileHeader:EditProfileButtonClicked') - store.shell.openModal({ + openModal({ name: 'edit-profile', - profileView: view, - onUpdate: onRefreshAll, + profile, }) - }, [track, store, view, onRefreshAll]) - - const trackPress = React.useCallback( - (f: 'Followers' | 'Follows') => { - track(`ProfileHeader:${f}ButtonClicked`, { - handle: view.handle, - }) - }, - [track, view], - ) + }, [track, openModal, profile]) const onPressShare = React.useCallback(() => { track('ProfileHeader:ShareButtonClicked') - const url = toShareUrl(makeProfileLink(view)) - shareUrl(url) - }, [track, view]) + shareUrl(toShareUrl(makeProfileLink(profile))) + }, [track, profile]) const onPressAddRemoveLists = React.useCallback(() => { track('ProfileHeader:AddToListsButtonClicked') - store.shell.openModal({ + openModal({ name: 'user-add-remove-lists', - subject: view.did, - displayName: view.displayName || view.handle, + subject: profile.did, + displayName: profile.displayName || profile.handle, + onAdd: invalidateProfileQuery, + onRemove: invalidateProfileQuery, }) - }, [track, view, store]) + }, [track, profile, openModal, invalidateProfileQuery]) const onPressMuteAccount = React.useCallback(async () => { track('ProfileHeader:MuteAccountButtonClicked') try { - await view.muteAccount() + await queueMute() Toast.show('Account muted') } catch (e: any) { - logger.error('Failed to mute account', {error: e}) - Toast.show(`There was an issue! ${e.toString()}`) + if (e?.name !== 'AbortError') { + logger.error('Failed to mute account', {error: e}) + Toast.show(`There was an issue! ${e.toString()}`) + } } - }, [track, view]) + }, [track, queueMute]) const onPressUnmuteAccount = React.useCallback(async () => { track('ProfileHeader:UnmuteAccountButtonClicked') try { - await view.unmuteAccount() + await queueUnmute() Toast.show('Account unmuted') } catch (e: any) { - logger.error('Failed to unmute account', {error: e}) - Toast.show(`There was an issue! ${e.toString()}`) + if (e?.name !== 'AbortError') { + logger.error('Failed to unmute account', {error: e}) + Toast.show(`There was an issue! ${e.toString()}`) + } } - }, [track, view]) + }, [track, queueUnmute]) const onPressBlockAccount = React.useCallback(async () => { track('ProfileHeader:BlockAccountButtonClicked') - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Block Account', - message: - 'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.', + title: _(msg`Block Account`), + message: _( + msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, + ), onPressConfirm: async () => { try { - await view.blockAccount() - onRefreshAll() + await queueBlock() Toast.show('Account blocked') } catch (e: any) { - logger.error('Failed to block account', {error: e}) - Toast.show(`There was an issue! ${e.toString()}`) + if (e?.name !== 'AbortError') { + logger.error('Failed to block account', {error: e}) + Toast.show(`There was an issue! ${e.toString()}`) + } } }, }) - }, [track, view, store, onRefreshAll]) + }, [track, queueBlock, openModal, _]) const onPressUnblockAccount = React.useCallback(async () => { track('ProfileHeader:UnblockAccountButtonClicked') - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Unblock Account', - message: - 'The account will be able to interact with you after unblocking.', + title: _(msg`Unblock Account`), + message: _( + msg`The account will be able to interact with you after unblocking.`, + ), onPressConfirm: async () => { try { - await view.unblockAccount() - onRefreshAll() + await queueUnblock() Toast.show('Account unblocked') } catch (e: any) { - logger.error('Failed to unblock account', {error: e}) - Toast.show(`There was an issue! ${e.toString()}`) + if (e?.name !== 'AbortError') { + logger.error('Failed to unblock account', {error: e}) + Toast.show(`There was an issue! ${e.toString()}`) + } } }, }) - }, [track, view, store, onRefreshAll]) + }, [track, queueUnblock, openModal, _]) const onPressReportAccount = React.useCallback(() => { track('ProfileHeader:ReportAccountButtonClicked') - store.shell.openModal({ + openModal({ name: 'report', - did: view.did, + did: profile.did, }) - }, [track, store, view]) + }, [track, openModal, profile]) const isMe = React.useMemo( - () => store.me.did === view.did, - [store.me.did, view.did], + () => currentAccount?.did === profile.did, + [currentAccount, profile], ) const dropdownItems: DropdownItem[] = React.useMemo(() => { let items: DropdownItem[] = [ { testID: 'profileHeaderDropdownShareBtn', - label: 'Share', + label: isWeb ? _(msg`Copy link to profile`) : _(msg`Share`), onPress: onPressShare, icon: { ios: { @@ -277,71 +320,81 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ }, }, ] - items.push({label: 'separator'}) - items.push({ - testID: 'profileHeaderDropdownListAddRemoveBtn', - label: 'Add to Lists', - onPress: onPressAddRemoveLists, - icon: { - ios: { - name: 'list.bullet', + if (hasSession) { + items.push({label: 'separator'}) + items.push({ + testID: 'profileHeaderDropdownListAddRemoveBtn', + label: _(msg`Add to Lists`), + onPress: onPressAddRemoveLists, + icon: { + ios: { + name: 'list.bullet', + }, + android: 'ic_menu_add', + web: 'list', }, - android: 'ic_menu_add', - web: 'list', - }, - }) - if (!isMe) { - if (!view.viewer.blocking) { - items.push({ - testID: 'profileHeaderDropdownMuteBtn', - label: view.viewer.muted ? 'Unmute Account' : 'Mute Account', - onPress: view.viewer.muted - ? onPressUnmuteAccount - : onPressMuteAccount, - icon: { - ios: { - name: 'speaker.slash', + }) + if (!isMe) { + if (!profile.viewer?.blocking) { + if (!profile.viewer?.mutedByList) { + items.push({ + testID: 'profileHeaderDropdownMuteBtn', + label: profile.viewer?.muted + ? _(msg`Unmute Account`) + : _(msg`Mute Account`), + onPress: profile.viewer?.muted + ? onPressUnmuteAccount + : onPressMuteAccount, + icon: { + ios: { + name: 'speaker.slash', + }, + android: 'ic_lock_silent_mode', + web: 'comment-slash', + }, + }) + } + } + if (!profile.viewer?.blockingByList) { + items.push({ + testID: 'profileHeaderDropdownBlockBtn', + label: profile.viewer?.blocking + ? _(msg`Unblock Account`) + : _(msg`Block Account`), + onPress: profile.viewer?.blocking + ? onPressUnblockAccount + : onPressBlockAccount, + icon: { + ios: { + name: 'person.fill.xmark', + }, + android: 'ic_menu_close_clear_cancel', + web: 'user-slash', }, - android: 'ic_lock_silent_mode', - web: 'comment-slash', - }, - }) - } - if (!view.viewer.blockingByList) { + }) + } items.push({ - testID: 'profileHeaderDropdownBlockBtn', - label: view.viewer.blocking ? 'Unblock Account' : 'Block Account', - onPress: view.viewer.blocking - ? onPressUnblockAccount - : onPressBlockAccount, + testID: 'profileHeaderDropdownReportBtn', + label: _(msg`Report Account`), + onPress: onPressReportAccount, icon: { ios: { - name: 'person.fill.xmark', + name: 'exclamationmark.triangle', }, - android: 'ic_menu_close_clear_cancel', - web: 'user-slash', + android: 'ic_menu_report_image', + web: 'circle-exclamation', }, }) } - items.push({ - testID: 'profileHeaderDropdownReportBtn', - label: 'Report Account', - onPress: onPressReportAccount, - icon: { - ios: { - name: 'exclamationmark.triangle', - }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', - }, - }) } return items }, [ isMe, - view.viewer.muted, - view.viewer.blocking, - view.viewer.blockingByList, + hasSession, + profile.viewer?.muted, + profile.viewer?.mutedByList, + profile.viewer?.blocking, + profile.viewer?.blockingByList, onPressShare, onPressUnmuteAccount, onPressMuteAccount, @@ -349,16 +402,18 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ onPressBlockAccount, onPressReportAccount, onPressAddRemoveLists, + _, ]) - const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy) - const following = formatCount(view.followsCount) - const followers = formatCount(view.followersCount) - const pluralizedFollowers = pluralize(view.followersCount, 'follower') + const blockHide = + !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy) + const following = formatCount(profile.followsCount || 0) + const followers = formatCount(profile.followersCount || 0) + const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') return ( <View style={pal.view}> - <UserBanner banner={view.banner} moderation={view.moderation.avatar} /> + <UserBanner banner={profile.banner} moderation={moderation.avatar} /> <View style={styles.content}> <View style={[styles.buttonsLine]}> {isMe ? ( @@ -367,29 +422,29 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ onPress={onPressEditProfile} style={[styles.btn, styles.mainBtn, pal.btn]} accessibilityRole="button" - accessibilityLabel="Edit profile" + accessibilityLabel={_(msg`Edit profile`)} accessibilityHint="Opens editor for profile display name, avatar, background image, and description"> <Text type="button" style={pal.text}> - Edit Profile + <Trans>Edit Profile</Trans> </Text> </TouchableOpacity> - ) : view.viewer.blocking ? ( - view.viewer.blockingByList ? null : ( + ) : profile.viewer?.blocking ? ( + profile.viewer?.blockingByList ? null : ( <TouchableOpacity testID="unblockBtn" onPress={onPressUnblockAccount} style={[styles.btn, styles.mainBtn, pal.btn]} accessibilityRole="button" - accessibilityLabel="Unblock" + accessibilityLabel={_(msg`Unblock`)} accessibilityHint=""> <Text type="button" style={[pal.text, s.bold]}> - Unblock + <Trans>Unblock</Trans> </Text> </TouchableOpacity> ) - ) : !view.viewer.blockedBy ? ( + ) : !profile.viewer?.blockedBy ? ( <> - {!isProfilePreview && ( + {!isProfilePreview && hasSession && ( <TouchableOpacity testID="suggestedFollowsBtn" onPress={() => setShowSuggestedFollows(!showSuggestedFollows)} @@ -405,7 +460,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ }, ]} accessibilityRole="button" - accessibilityLabel={`Show follows similar to ${view.handle}`} + accessibilityLabel={`Show follows similar to ${profile.handle}`} accessibilityHint={`Shows a list of users similar to this user.`}> <FontAwesomeIcon icon="user-plus" @@ -413,7 +468,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ pal.text, { color: showSuggestedFollows - ? colors.white + ? pal.textInverted.color : pal.text.color, }, ]} @@ -422,38 +477,37 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ </TouchableOpacity> )} - {store.me.follows.getFollowState(view.did) === - FollowState.Following ? ( + {profile.viewer?.following ? ( <TouchableOpacity testID="unfollowBtn" - onPress={onPressToggleFollow} + onPress={onPressUnfollow} style={[styles.btn, styles.mainBtn, pal.btn]} accessibilityRole="button" - accessibilityLabel={`Unfollow ${view.handle}`} - accessibilityHint={`Hides posts from ${view.handle} in your feed`}> + accessibilityLabel={`Unfollow ${profile.handle}`} + accessibilityHint={`Hides posts from ${profile.handle} in your feed`}> <FontAwesomeIcon icon="check" style={[pal.text, s.mr5]} size={14} /> <Text type="button" style={pal.text}> - Following + <Trans>Following</Trans> </Text> </TouchableOpacity> ) : ( <TouchableOpacity testID="followBtn" - onPress={onPressToggleFollow} + onPress={onPressFollow} style={[styles.btn, styles.mainBtn, palInverted.view]} accessibilityRole="button" - accessibilityLabel={`Follow ${view.handle}`} - accessibilityHint={`Shows posts from ${view.handle} in your feed`}> + accessibilityLabel={`Follow ${profile.handle}`} + accessibilityHint={`Shows posts from ${profile.handle} in your feed`}> <FontAwesomeIcon icon="plus" style={[palInverted.text, s.mr5]} /> <Text type="button" style={[palInverted.text, s.bold]}> - Follow + <Trans>Follow</Trans> </Text> </TouchableOpacity> )} @@ -463,7 +517,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ <NativeDropdown testID="profileHeaderDropdownBtn" items={dropdownItems} - accessibilityLabel="More options" + accessibilityLabel={_(msg`More options`)} accessibilityHint=""> <View style={[styles.btn, styles.secondaryBtn, pal.btn]}> <FontAwesomeIcon icon="ellipsis" size={20} style={[pal.text]} /> @@ -477,16 +531,16 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ type="title-2xl" style={[pal.text, styles.title]}> {sanitizeDisplayName( - view.displayName || sanitizeHandle(view.handle), - view.moderation.profile, + profile.displayName || sanitizeHandle(profile.handle), + moderation.profile, )} </Text> </View> <View style={styles.handleLine}> - {view.viewer.followedBy && !blockHide ? ( + {profile.viewer?.followedBy && !blockHide ? ( <View style={[styles.pill, pal.btn, s.mr5]}> <Text type="xs" style={[pal.text]}> - Follows you + <Trans>Follows you</Trans> </Text> </View> ) : undefined} @@ -498,7 +552,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ invalidHandle ? styles.invalidHandle : undefined, styles.handle, ]}> - {invalidHandle ? 'âš Invalid Handle' : `@${view.handle}`} + {invalidHandle ? 'âš Invalid Handle' : `@${profile.handle}`} </ThemedText> </View> {!blockHide && ( @@ -507,8 +561,12 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ <Link testID="profileHeaderFollowersButton" style={[s.flexRow, s.mr10]} - href={makeProfileLink(view, 'followers')} - onPressOut={() => trackPress('Followers')} + href={makeProfileLink(profile, 'followers')} + onPressOut={() => + track(`ProfileHeader:FollowersButtonClicked`, { + handle: profile.handle, + }) + } asAnchor accessibilityLabel={`${followers} ${pluralizedFollowers}`} accessibilityHint={'Opens followers list'}> @@ -522,8 +580,12 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ <Link testID="profileHeaderFollowsButton" style={[s.flexRow, s.mr10]} - href={makeProfileLink(view, 'follows')} - onPressOut={() => trackPress('Follows')} + href={makeProfileLink(profile, 'follows')} + onPressOut={() => + track(`ProfileHeader:FollowsButtonClicked`, { + handle: profile.handle, + }) + } asAnchor accessibilityLabel={`${following} following`} accessibilityHint={'Opens following list'}> @@ -531,34 +593,32 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ {following}{' '} </Text> <Text type="md" style={[pal.textLight]}> - following + <Trans>following</Trans> </Text> </Link> <Text type="md" style={[s.bold, pal.text]}> - {formatCount(view.postsCount)}{' '} + {formatCount(profile.postsCount || 0)}{' '} <Text type="md" style={[pal.textLight]}> - {pluralize(view.postsCount, 'post')} + {pluralize(profile.postsCount || 0, 'post')} </Text> </Text> </View> - {view.description && - view.descriptionRichText && - !view.moderation.profile.blur ? ( + {descriptionRT && !moderation.profile.blur ? ( <RichText testID="profileHeaderDescription" style={[styles.description, pal.text]} numberOfLines={15} - richText={view.descriptionRichText} + richText={descriptionRT} /> ) : undefined} </> )} - <ProfileHeaderAlerts moderation={view.moderation} /> + <ProfileHeaderAlerts moderation={moderation} /> </View> {!isProfilePreview && ( <ProfileHeaderSuggestedFollows - actorDid={view.did} + actorDid={profile.did} active={showSuggestedFollows} requestDismiss={() => setShowSuggestedFollows(!showSuggestedFollows)} /> @@ -570,7 +630,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ onPress={onPressBack} hitSlop={BACK_HITSLOP} accessibilityRole="button" - accessibilityLabel="Back" + accessibilityLabel={_(msg`Back`)} accessibilityHint=""> <View style={styles.backBtnWrapper}> <BlurView style={styles.backBtn} blurType="dark"> @@ -583,20 +643,21 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ testID="profileHeaderAviButton" onPress={onPressAvi} accessibilityRole="image" - accessibilityLabel={`View ${view.handle}'s avatar`} + accessibilityLabel={`View ${profile.handle}'s avatar`} accessibilityHint=""> <View style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> <UserAvatar size={80} - avatar={view.avatar} - moderation={view.moderation.avatar} + avatar={profile.avatar} + moderation={moderation.avatar} /> </View> </TouchableWithoutFeedback> </View> ) -}) +} +ProfileHeaderLoaded = memo(ProfileHeaderLoaded) const styles = StyleSheet.create({ banner: { diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx index cf759ddd1..f648c9801 100644 --- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx +++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx @@ -6,20 +6,16 @@ import Animated, { useAnimatedStyle, Easing, } from 'react-native-reanimated' -import {useQuery} from '@tanstack/react-query' import {AppBskyActorDefs, moderateProfile} from '@atproto/api' -import {observer} from 'mobx-react-lite' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import * as Toast from '../util/Toast' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {Text} from 'view/com/util/text/Text' import {UserAvatar} from 'view/com/util/UserAvatar' -import {useFollowProfile} from 'lib/hooks/useFollowProfile' import {Button} from 'view/com/util/forms/Button' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' @@ -27,6 +23,10 @@ import {makeProfileLink} from 'lib/routes/links' import {Link} from 'view/com/util/Link' import {useAnalytics} from 'lib/analytics/analytics' import {isWeb} from 'platform/detection' +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' const OUTER_PADDING = 10 const INNER_PADDING = 14 @@ -43,7 +43,6 @@ export function ProfileHeaderSuggestedFollows({ }) { const {track} = useAnalytics() const pal = usePalette('default') - const store = useStores() const animatedHeight = useSharedValue(0) const animatedStyles = useAnimatedStyle(() => ({ opacity: animatedHeight.value / TOTAL_HEIGHT, @@ -66,31 +65,8 @@ export function ProfileHeaderSuggestedFollows({ } }, [active, animatedHeight, track]) - const {isLoading, data: suggestedFollows} = useQuery({ - enabled: active, - cacheTime: 0, - staleTime: 0, - queryKey: ['suggested_follows_by_actor', actorDid], - async queryFn() { - try { - const { - data: {suggestions}, - success, - } = await store.agent.app.bsky.graph.getSuggestedFollowsByActor({ - actor: actorDid, - }) - - if (!success) { - return [] - } - - store.me.follows.hydrateMany(suggestions) - - return suggestions - } catch (e) { - return [] - } - }, + const {isLoading, data} = useSuggestedFollowsByActorQuery({ + did: actorDid, }) return ( @@ -149,8 +125,8 @@ export function ProfileHeaderSuggestedFollows({ <SuggestedFollowSkeleton /> <SuggestedFollowSkeleton /> </> - ) : suggestedFollows ? ( - suggestedFollows.map(profile => ( + ) : data ? ( + data.suggestions.map(profile => ( <SuggestedFollow key={profile.did} profile={profile} /> )) ) : ( @@ -214,29 +190,43 @@ function SuggestedFollowSkeleton() { ) } -const SuggestedFollow = observer(function SuggestedFollowImpl({ - profile, +function SuggestedFollow({ + profile: profileUnshadowed, }: { profile: AppBskyActorDefs.ProfileView }) { const {track} = useAnalytics() const pal = usePalette('default') - const store = useStores() - const {following, toggle} = useFollowProfile(profile) - const moderation = moderateProfile(profile, store.preferences.moderationOpts) + const moderationOpts = useModerationOpts() + const profile = useProfileShadow(profileUnshadowed) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) - const onPress = React.useCallback(async () => { + const onPressFollow = React.useCallback(async () => { try { - const {following: isFollowing} = await toggle() - - if (isFollowing) { - track('ProfileHeader:SuggestedFollowFollowed') + track('ProfileHeader:SuggestedFollowFollowed') + await queueFollow() + } catch (e: any) { + if (e?.name !== 'AbortError') { + Toast.show('An issue occurred, please try again.') } + } + }, [queueFollow, track]) + + const onPressUnfollow = React.useCallback(async () => { + try { + await queueUnfollow() } catch (e: any) { - Toast.show('An issue occurred, please try again.') + if (e?.name !== 'AbortError') { + Toast.show('An issue occurred, please try again.') + } } - }, [toggle, track]) + }, [queueUnfollow]) + if (!moderationOpts) { + return null + } + const moderation = moderateProfile(profile, moderationOpts) + const following = profile.viewer?.following return ( <Link href={makeProfileLink(profile)} @@ -278,13 +268,12 @@ const SuggestedFollow = observer(function SuggestedFollowImpl({ label={following ? 'Unfollow' : 'Follow'} type="inverted" labelStyle={{textAlign: 'center'}} - onPress={onPress} - withLoading + onPress={following ? onPressUnfollow : onPressFollow} /> </View> </Link> ) -}) +} const styles = StyleSheet.create({ suggestedFollowCardOuter: { diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx index 0b8015aa9..0e245f0f4 100644 --- a/src/view/com/profile/ProfileSubpageHeader.tsx +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -1,6 +1,5 @@ import React from 'react' import {Pressable, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' import {usePalette} from 'lib/hooks/usePalette' @@ -12,14 +11,16 @@ import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {CenteredView} from '../util/Views' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' -import {useStores} from 'state/index' import {NavigationProp} from 'lib/routes/types' import {BACK_HITSLOP} from 'lib/constants' import {isNative} from 'platform/detection' -import {ImagesLightbox} from 'state/models/ui/shell' +import {useLightboxControls, ImagesLightbox} from '#/state/lightbox' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' import {useSetDrawerOpen} from '#/state/shell' +import {emitSoftReset} from '#/state/events' -export const ProfileSubpageHeader = observer(function HeaderImpl({ +export function ProfileSubpageHeader({ isLoading, href, title, @@ -42,10 +43,11 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({ | undefined avatarType: UserAvatarType }>) { - const store = useStores() const setDrawerOpen = useSetDrawerOpen() const navigation = useNavigation<NavigationProp>() + const {_} = useLingui() const {isMobile} = useWebMediaQueries() + const {openLightbox} = useLightboxControls() const pal = usePalette('default') const canGoBack = navigation.canGoBack() @@ -65,9 +67,9 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({ if ( avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) ) { - store.shell.openLightbox(new ImagesLightbox([{uri: avatar}], 0)) + openLightbox(new ImagesLightbox([{uri: avatar}], 0)) } - }, [store, avatar]) + }, [openLightbox, avatar]) return ( <CenteredView style={pal.view}> @@ -123,7 +125,7 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({ testID="headerAviButton" onPress={onPressAvi} accessibilityRole="image" - accessibilityLabel="View the avatar" + accessibilityLabel={_(msg`View the avatar`)} accessibilityHint="" style={{width: 58}}> <UserAvatar type={avatarType} size={58} avatar={avatar} /> @@ -142,7 +144,7 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({ href={href} style={[pal.text, {fontWeight: 'bold'}]} text={title || ''} - onPress={() => store.emitScreenSoftReset()} + onPress={emitSoftReset} numberOfLines={4} /> )} @@ -178,7 +180,7 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({ </View> </CenteredView> ) -}) +} const styles = StyleSheet.create({ backBtn: { diff --git a/src/view/com/search/HeaderWithInput.tsx b/src/view/com/search/HeaderWithInput.tsx deleted file mode 100644 index 1a6b427c6..000000000 --- a/src/view/com/search/HeaderWithInput.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import React from 'react' -import {StyleSheet, TextInput, TouchableOpacity, View} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {Text} from 'view/com/util/text/Text' -import {MagnifyingGlassIcon} from 'lib/icons' -import {useTheme} from 'lib/ThemeContext' -import {usePalette} from 'lib/hooks/usePalette' -import {useAnalytics} from 'lib/analytics/analytics' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {HITSLOP_10} from 'lib/constants' -import {useSetDrawerOpen} from '#/state/shell' - -interface Props { - isInputFocused: boolean - query: string - setIsInputFocused: (v: boolean) => void - onChangeQuery: (v: string) => void - onPressClearQuery: () => void - onPressCancelSearch: () => void - onSubmitQuery: () => void - showMenu?: boolean -} -export function HeaderWithInput({ - isInputFocused, - query, - setIsInputFocused, - onChangeQuery, - onPressClearQuery, - onPressCancelSearch, - onSubmitQuery, - showMenu = true, -}: Props) { - const setDrawerOpen = useSetDrawerOpen() - const theme = useTheme() - const pal = usePalette('default') - const {track} = useAnalytics() - const textInput = React.useRef<TextInput>(null) - const {isMobile} = useWebMediaQueries() - - const onPressMenu = React.useCallback(() => { - track('ViewHeader:MenuButtonClicked') - setDrawerOpen(true) - }, [track, setDrawerOpen]) - - const onPressCancelSearchInner = React.useCallback(() => { - onPressCancelSearch() - textInput.current?.blur() - }, [onPressCancelSearch, textInput]) - - return ( - <View - style={[ - pal.view, - pal.border, - styles.header, - !isMobile && styles.headerDesktop, - ]}> - {showMenu && isMobile ? ( - <TouchableOpacity - testID="viewHeaderBackOrMenuBtn" - onPress={onPressMenu} - hitSlop={HITSLOP_10} - style={styles.headerMenuBtn} - accessibilityRole="button" - accessibilityLabel="Menu" - accessibilityHint="Access navigation links and settings"> - <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} /> - </TouchableOpacity> - ) : null} - <View - style={[ - {backgroundColor: pal.colors.backgroundLight}, - styles.headerSearchContainer, - ]}> - <MagnifyingGlassIcon - style={[pal.icon, styles.headerSearchIcon]} - size={21} - /> - <TextInput - testID="searchTextInput" - ref={textInput} - placeholder="Search" - placeholderTextColor={pal.colors.textLight} - selectTextOnFocus - returnKeyType="search" - value={query} - style={[pal.text, styles.headerSearchInput]} - keyboardAppearance={theme.colorScheme} - onFocus={() => setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - onChangeText={onChangeQuery} - onSubmitEditing={onSubmitQuery} - autoFocus={false} - accessibilityRole="search" - accessibilityLabel="Search" - accessibilityHint="" - autoCorrect={false} - autoCapitalize="none" - /> - {query ? ( - <TouchableOpacity - testID="searchTextInputClearBtn" - onPress={onPressClearQuery} - accessibilityRole="button" - accessibilityLabel="Clear search query" - accessibilityHint=""> - <FontAwesomeIcon - icon="xmark" - size={16} - style={pal.textLight as FontAwesomeIconStyle} - /> - </TouchableOpacity> - ) : undefined} - </View> - {query || isInputFocused ? ( - <View style={styles.headerCancelBtn}> - <TouchableOpacity - onPress={onPressCancelSearchInner} - accessibilityRole="button"> - <Text style={pal.text}>Cancel</Text> - </TouchableOpacity> - </View> - ) : undefined} - </View> - ) -} - -const styles = StyleSheet.create({ - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 12, - paddingVertical: 4, - }, - headerDesktop: { - borderWidth: 1, - borderTopWidth: 0, - paddingVertical: 10, - }, - headerMenuBtn: { - width: 30, - height: 30, - borderRadius: 30, - marginRight: 6, - paddingBottom: 2, - alignItems: 'center', - justifyContent: 'center', - }, - headerSearchContainer: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - borderRadius: 30, - paddingHorizontal: 12, - paddingVertical: 8, - }, - headerSearchIcon: { - marginRight: 6, - alignSelf: 'center', - }, - headerSearchInput: { - flex: 1, - fontSize: 17, - }, - headerCancelBtn: { - paddingLeft: 10, - }, - - searchPrompt: { - textAlign: 'center', - paddingTop: 10, - }, - - suggestions: { - marginBottom: 8, - }, -}) diff --git a/src/view/com/search/SearchResults.tsx b/src/view/com/search/SearchResults.tsx deleted file mode 100644 index 87378bba7..000000000 --- a/src/view/com/search/SearchResults.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {SearchUIModel} from 'state/models/ui/search' -import {CenteredView, ScrollView} from '../util/Views' -import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager' -import {TabBar} from 'view/com/pager/TabBar' -import {Post} from 'view/com/post/Post' -import {ProfileCardWithFollowBtn} from 'view/com/profile/ProfileCard' -import { - PostFeedLoadingPlaceholder, - ProfileCardFeedLoadingPlaceholder, -} from 'view/com/util/LoadingPlaceholder' -import {Text} from 'view/com/util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {s} from 'lib/styles' - -const SECTIONS = ['Posts', 'Users'] - -export const SearchResults = observer(function SearchResultsImpl({ - model, -}: { - model: SearchUIModel -}) { - const pal = usePalette('default') - const {isMobile} = useWebMediaQueries() - - const renderTabBar = React.useCallback( - (props: RenderTabBarFnProps) => { - return ( - <CenteredView style={[pal.border, pal.view, styles.tabBar]}> - <TabBar - items={SECTIONS} - {...props} - key={SECTIONS.join()} - indicatorColor={pal.colors.link} - /> - </CenteredView> - ) - }, - [pal], - ) - - return ( - <Pager renderTabBar={renderTabBar} tabBarPosition="top" initialPage={0}> - <View - style={{ - paddingTop: isMobile ? 42 : 50, - }}> - <PostResults key="0" model={model} /> - </View> - <View - style={{ - paddingTop: isMobile ? 42 : 50, - }}> - <Profiles key="1" model={model} /> - </View> - </Pager> - ) -}) - -const PostResults = observer(function PostResultsImpl({ - model, -}: { - model: SearchUIModel -}) { - const pal = usePalette('default') - if (model.isPostsLoading) { - return ( - <CenteredView> - <PostFeedLoadingPlaceholder /> - </CenteredView> - ) - } - - if (model.posts.length === 0) { - return ( - <CenteredView> - <Text type="xl" style={[styles.empty, pal.text]}> - No posts found for "{model.query}" - </Text> - </CenteredView> - ) - } - - return ( - <ScrollView style={[pal.view]}> - {model.posts.map(post => ( - <Post key={post.resolvedUri} view={post} hideError /> - ))} - <View style={s.footerSpacer} /> - <View style={s.footerSpacer} /> - <View style={s.footerSpacer} /> - </ScrollView> - ) -}) - -const Profiles = observer(function ProfilesImpl({ - model, -}: { - model: SearchUIModel -}) { - const pal = usePalette('default') - if (model.isProfilesLoading) { - return ( - <CenteredView> - <ProfileCardFeedLoadingPlaceholder /> - </CenteredView> - ) - } - - if (model.profiles.length === 0) { - return ( - <CenteredView> - <Text type="xl" style={[styles.empty, pal.text]}> - No users found for "{model.query}" - </Text> - </CenteredView> - ) - } - - return ( - <ScrollView style={pal.view}> - {model.profiles.map(item => ( - <ProfileCardWithFollowBtn key={item.did} profile={item} /> - ))} - <View style={s.footerSpacer} /> - <View style={s.footerSpacer} /> - <View style={s.footerSpacer} /> - </ScrollView> - ) -}) - -const styles = StyleSheet.create({ - tabBar: { - borderBottomWidth: 1, - position: 'absolute', - zIndex: 1, - left: 0, - right: 0, - top: 0, - flexDirection: 'column', - alignItems: 'center', - }, - empty: { - paddingHorizontal: 14, - paddingVertical: 16, - }, -}) diff --git a/src/view/com/search/Suggestions.tsx b/src/view/com/search/Suggestions.tsx deleted file mode 100644 index 2a80d10ae..000000000 --- a/src/view/com/search/Suggestions.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import React, {forwardRef, ForwardedRef} from 'react' -import {RefreshControl, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {AppBskyActorDefs} from '@atproto/api' -import {FlatList} from '../util/Views' -import {FoafsModel} from 'state/models/discovery/foafs' -import { - SuggestedActorsModel, - SuggestedActor, -} from 'state/models/discovery/suggested-actors' -import {Text} from '../util/text/Text' -import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' -import {ProfileCardLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' -import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs' -import {usePalette} from 'lib/hooks/usePalette' -import {s} from 'lib/styles' - -interface Heading { - _reactKey: string - type: 'heading' - title: string -} -interface RefWrapper { - _reactKey: string - type: 'ref' - ref: RefWithInfoAndFollowers -} -interface SuggestWrapper { - _reactKey: string - type: 'suggested' - suggested: SuggestedActor -} -interface ProfileView { - _reactKey: string - type: 'profile-view' - view: AppBskyActorDefs.ProfileViewBasic -} -interface LoadingPlaceholder { - _reactKey: string - type: 'loading-placeholder' -} -type Item = - | Heading - | RefWrapper - | SuggestWrapper - | ProfileView - | LoadingPlaceholder - -// FIXME(dan): Figure out why the false positives -/* eslint-disable react/prop-types */ - -export const Suggestions = observer( - forwardRef(function SuggestionsImpl( - { - foafs, - suggestedActors, - }: { - foafs: FoafsModel - suggestedActors: SuggestedActorsModel - }, - flatListRef: ForwardedRef<FlatList>, - ) { - const pal = usePalette('default') - const [refreshing, setRefreshing] = React.useState(false) - const data = React.useMemo(() => { - let items: Item[] = [] - - if (suggestedActors.hasContent) { - items = items - .concat([ - { - _reactKey: '__suggested_heading__', - type: 'heading', - title: 'Suggested Follows', - }, - ]) - .concat( - suggestedActors.suggestions.map(suggested => ({ - _reactKey: `suggested-${suggested.did}`, - type: 'suggested', - suggested, - })), - ) - } else if (suggestedActors.isLoading) { - items = items.concat([ - { - _reactKey: '__suggested_heading__', - type: 'heading', - title: 'Suggested Follows', - }, - {_reactKey: '__suggested_loading__', type: 'loading-placeholder'}, - ]) - } - if (foafs.isLoading) { - items = items.concat([ - { - _reactKey: '__popular_heading__', - type: 'heading', - title: 'In Your Network', - }, - {_reactKey: '__foafs_loading__', type: 'loading-placeholder'}, - ]) - } else { - if (foafs.popular.length > 0) { - items = items - .concat([ - { - _reactKey: '__popular_heading__', - type: 'heading', - title: 'In Your Network', - }, - ]) - .concat( - foafs.popular.map(ref => ({ - _reactKey: `popular-${ref.did}`, - type: 'ref', - ref, - })), - ) - } - for (const source of foafs.sources) { - const item = foafs.foafs.get(source) - if (!item || item.follows.length === 0) { - continue - } - items = items - .concat([ - { - _reactKey: `__${item.did}_heading__`, - type: 'heading', - title: `Followed by ${sanitizeDisplayName( - item.displayName || sanitizeHandle(item.handle), - )}`, - }, - ]) - .concat( - item.follows.slice(0, 10).map(view => ({ - _reactKey: `${item.did}-${view.did}`, - type: 'profile-view', - view, - })), - ) - } - } - - return items - }, [ - foafs.isLoading, - foafs.popular, - suggestedActors.isLoading, - suggestedActors.hasContent, - suggestedActors.suggestions, - foafs.sources, - foafs.foafs, - ]) - - const onRefresh = React.useCallback(async () => { - setRefreshing(true) - try { - await foafs.fetch() - } finally { - setRefreshing(false) - } - }, [foafs, setRefreshing]) - - const renderItem = React.useCallback( - ({item}: {item: Item}) => { - if (item.type === 'heading') { - return ( - <Text type="title" style={[styles.heading, pal.text]}> - {item.title} - </Text> - ) - } - if (item.type === 'ref') { - return ( - <View style={[styles.card, pal.view, pal.border]}> - <ProfileCardWithFollowBtn - key={item.ref.did} - profile={item.ref} - noBg - noBorder - followers={ - item.ref.followers - ? (item.ref.followers as AppBskyActorDefs.ProfileView[]) - : undefined - } - /> - </View> - ) - } - if (item.type === 'profile-view') { - return ( - <View style={[styles.card, pal.view, pal.border]}> - <ProfileCardWithFollowBtn - key={item.view.did} - profile={item.view} - noBg - noBorder - /> - </View> - ) - } - if (item.type === 'suggested') { - return ( - <View style={[styles.card, pal.view, pal.border]}> - <ProfileCardWithFollowBtn - key={item.suggested.did} - profile={item.suggested} - noBg - noBorder - /> - </View> - ) - } - if (item.type === 'loading-placeholder') { - return ( - <View> - <ProfileCardLoadingPlaceholder /> - <ProfileCardLoadingPlaceholder /> - <ProfileCardLoadingPlaceholder /> - <ProfileCardLoadingPlaceholder /> - </View> - ) - } - return null - }, - [pal], - ) - - return ( - <FlatList - ref={flatListRef} - data={data} - keyExtractor={item => item._reactKey} - refreshControl={ - <RefreshControl - refreshing={refreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - renderItem={renderItem} - initialNumToRender={15} - contentContainerStyle={s.contentContainer} - /> - ) - }), -) - -const styles = StyleSheet.create({ - heading: { - fontWeight: 'bold', - paddingHorizontal: 12, - paddingBottom: 8, - paddingTop: 16, - }, - - card: { - borderTopWidth: 1, - }, -}) diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx index db9b6b4bf..41abc25d3 100644 --- a/src/view/com/testing/TestCtrls.e2e.tsx +++ b/src/view/com/testing/TestCtrls.e2e.tsx @@ -1,7 +1,10 @@ import React from 'react' import {Pressable, View} from 'react-native' -import {useStores} from 'state/index' import {navigate} from '../../../Navigation' +import {useModalControls} from '#/state/modals' +import {useQueryClient} from '@tanstack/react-query' +import {useSessionApi} from '#/state/session' +import {useSetFeedViewPreferencesMutation} from '#/state/queries/preferences' /** * This utility component is only included in the test simulator @@ -12,16 +15,19 @@ import {navigate} from '../../../Navigation' const BTN = {height: 1, width: 1, backgroundColor: 'red'} export function TestCtrls() { - const store = useStores() + const queryClient = useQueryClient() + const {logout, login} = useSessionApi() + const {openModal} = useModalControls() + const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation() const onPressSignInAlice = async () => { - await store.session.login({ + await login({ service: 'http://localhost:3000', identifier: 'alice.test', password: 'hunter2', }) } const onPressSignInBob = async () => { - await store.session.login({ + await login({ service: 'http://localhost:3000', identifier: 'bob.test', password: 'hunter2', @@ -43,7 +49,7 @@ export function TestCtrls() { /> <Pressable testID="e2eSignOut" - onPress={() => store.session.logout()} + onPress={() => logout()} accessibilityRole="button" style={BTN} /> @@ -73,19 +79,19 @@ export function TestCtrls() { /> <Pressable testID="e2eToggleMergefeed" - onPress={() => store.preferences.toggleHomeFeedMergeFeedEnabled()} + onPress={() => setFeedViewPref({lab_mergeFeedEnabled: true})} accessibilityRole="button" style={BTN} /> <Pressable testID="e2eRefreshHome" - onPress={() => store.me.mainFeed.refresh()} + onPress={() => queryClient.invalidateQueries({queryKey: ['post-feed']})} accessibilityRole="button" style={BTN} /> <Pressable testID="e2eOpenInviteCodesModal" - onPress={() => store.shell.openModal({name: 'invite-codes'})} + onPress={() => openModal({name: 'invite-codes'})} accessibilityRole="button" style={BTN} /> diff --git a/src/view/com/util/AccountDropdownBtn.tsx b/src/view/com/util/AccountDropdownBtn.tsx index 29571696b..76d493886 100644 --- a/src/view/com/util/AccountDropdownBtn.tsx +++ b/src/view/com/util/AccountDropdownBtn.tsx @@ -5,19 +5,23 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {s} from 'lib/styles' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' import * as Toast from '../../com/util/Toast' +import {useSessionApi, SessionAccount} from '#/state/session' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' -export function AccountDropdownBtn({handle}: {handle: string}) { - const store = useStores() +export function AccountDropdownBtn({account}: {account: SessionAccount}) { const pal = usePalette('default') + const {removeAccount} = useSessionApi() + const {_} = useLingui() + const items: DropdownItem[] = [ { - label: 'Remove account', + label: _(msg`Remove account`), onPress: () => { - store.session.removeAccount(handle) + removeAccount(account) Toast.show('Account removed from quick access') }, icon: { @@ -34,7 +38,7 @@ export function AccountDropdownBtn({handle}: {handle: string}) { <NativeDropdown testID="accountSettingsDropdownBtn" items={items} - accessibilityLabel="Account options" + accessibilityLabel={_(msg`Account options`)} accessibilityHint=""> <FontAwesomeIcon icon="ellipsis-h" diff --git a/src/view/com/util/BottomSheetCustomBackdrop.tsx b/src/view/com/util/BottomSheetCustomBackdrop.tsx index 91379f1c9..ed5a2f165 100644 --- a/src/view/com/util/BottomSheetCustomBackdrop.tsx +++ b/src/view/com/util/BottomSheetCustomBackdrop.tsx @@ -6,6 +6,7 @@ import Animated, { interpolate, useAnimatedStyle, } from 'react-native-reanimated' +import {t} from '@lingui/macro' export function createCustomBackdrop( onClose?: (() => void) | undefined, @@ -29,7 +30,7 @@ export function createCustomBackdrop( return ( <TouchableWithoutFeedback onPress={onClose} - accessibilityLabel="Close bottom drawer" + accessibilityLabel={t`Close bottom drawer`} accessibilityHint="" onAccessibilityEscape={() => { if (onClose !== undefined) { diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx index 529435cf1..397588cfb 100644 --- a/src/view/com/util/ErrorBoundary.tsx +++ b/src/view/com/util/ErrorBoundary.tsx @@ -1,6 +1,7 @@ import React, {Component, ErrorInfo, ReactNode} from 'react' import {ErrorScreen} from './error/ErrorScreen' import {CenteredView} from './Views' +import {t} from '@lingui/macro' interface Props { children?: ReactNode @@ -30,8 +31,8 @@ export class ErrorBoundary extends Component<Props, State> { return ( <CenteredView style={{height: '100%', flex: 1}}> <ErrorScreen - title="Oh no!" - message="There was an unexpected issue in the application. Please let us know if this happened to you!" + title={t`Oh no!`} + message={t`There was an unexpected issue in the application. Please let us know if this happened to you!`} details={this.state.error.toString()} /> </CenteredView> diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 1777f6659..dcbec7cb4 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -21,7 +21,6 @@ import {Text} from './text/Text' import {TypographyVariant} from 'lib/ThemeContext' import {NavigationProp} from 'lib/routes/types' import {router} from '../../../routes' -import {useStores, RootStoreModel} from 'state/index' import { convertBskyAppUrlIfNeeded, isExternalUrl, @@ -31,6 +30,7 @@ import {isAndroid, isWeb} from 'platform/detection' import {sanitizeUrl} from '@braintree/sanitize-url' import {PressableWithHover} from './PressableWithHover' import FixedTouchableHighlight from '../pager/FixedTouchableHighlight' +import {useModalControls} from '#/state/modals' type Event = | React.MouseEvent<HTMLAnchorElement, MouseEvent> @@ -46,6 +46,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> { noFeedback?: boolean asAnchor?: boolean anchorNoUnderline?: boolean + navigationAction?: 'push' | 'replace' | 'navigate' } export const Link = memo(function Link({ @@ -58,19 +59,26 @@ export const Link = memo(function Link({ asAnchor, accessible, anchorNoUnderline, + navigationAction, ...props }: Props) { - const store = useStores() + const {closeModal} = useModalControls() const navigation = useNavigation<NavigationProp>() const anchorHref = asAnchor ? sanitizeUrl(href) : undefined const onPress = React.useCallback( (e?: Event) => { if (typeof href === 'string') { - return onPressInner(store, navigation, sanitizeUrl(href), e) + return onPressInner( + closeModal, + navigation, + sanitizeUrl(href), + navigationAction, + e, + ) } }, - [store, navigation, href], + [closeModal, navigation, navigationAction, href], ) if (noFeedback) { @@ -146,6 +154,7 @@ export const TextLink = memo(function TextLink({ title, onPress, warnOnMismatchingLabel, + navigationAction, ...orgProps }: { testID?: string @@ -158,10 +167,11 @@ export const TextLink = memo(function TextLink({ dataSet?: any title?: string warnOnMismatchingLabel?: boolean + navigationAction?: 'push' | 'replace' | 'navigate' } & TextProps) { const {...props} = useLinkProps({to: sanitizeUrl(href)}) - const store = useStores() const navigation = useNavigation<NavigationProp>() + const {openModal, closeModal} = useModalControls() if (warnOnMismatchingLabel && typeof text !== 'string') { console.error('Unable to detect mismatching label') @@ -174,7 +184,7 @@ export const TextLink = memo(function TextLink({ linkRequiresWarning(href, typeof text === 'string' ? text : '') if (requiresWarning) { e?.preventDefault?.() - store.shell.openModal({ + openModal({ name: 'link-warning', text: typeof text === 'string' ? text : '', href, @@ -185,9 +195,24 @@ export const TextLink = memo(function TextLink({ // @ts-ignore function signature differs by platform -prf return onPress() } - return onPressInner(store, navigation, sanitizeUrl(href), e) + return onPressInner( + closeModal, + navigation, + sanitizeUrl(href), + navigationAction, + e, + ) }, - [onPress, store, navigation, href, text, warnOnMismatchingLabel], + [ + onPress, + closeModal, + openModal, + navigation, + href, + text, + warnOnMismatchingLabel, + navigationAction, + ], ) const hrefAttrs = useMemo(() => { const isExternal = isExternalUrl(href) @@ -233,6 +258,7 @@ interface TextLinkOnWebOnlyProps extends TextProps { accessibilityLabel?: string accessibilityHint?: string title?: string + navigationAction?: 'push' | 'replace' | 'navigate' } export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ testID, @@ -242,6 +268,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ text, numberOfLines, lineHeight, + navigationAction, ...props }: TextLinkOnWebOnlyProps) { if (isWeb) { @@ -255,6 +282,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ numberOfLines={numberOfLines} lineHeight={lineHeight} title={props.title} + navigationAction={navigationAction} {...props} /> ) @@ -285,9 +313,10 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ // needed customizations // -prf function onPressInner( - store: RootStoreModel, + closeModal = () => {}, navigation: NavigationProp, href: string, + navigationAction: 'push' | 'replace' | 'navigate' = 'push', e?: Event, ) { let shouldHandle = false @@ -318,10 +347,20 @@ function onPressInner( if (newTab || href.startsWith('http') || href.startsWith('mailto')) { Linking.openURL(href) } else { - store.shell.closeModal() // close any active modals + closeModal() // close any active modals - // @ts-ignore we're not able to type check on this one -prf - navigation.dispatch(StackActions.push(...router.matchPath(href))) + if (navigationAction === 'push') { + // @ts-ignore we're not able to type check on this one -prf + navigation.dispatch(StackActions.push(...router.matchPath(href))) + } else if (navigationAction === 'replace') { + // @ts-ignore we're not able to type check on this one -prf + navigation.dispatch(StackActions.replace(...router.matchPath(href))) + } else if (navigationAction === 'navigate') { + // @ts-ignore we're not able to type check on this one -prf + navigation.navigate(...router.matchPath(href)) + } else { + throw Error('Unsupported navigator action.') + } } } } diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx index 461cbcbe5..74e36ff7b 100644 --- a/src/view/com/util/LoadingPlaceholder.tsx +++ b/src/view/com/util/LoadingPlaceholder.tsx @@ -171,14 +171,22 @@ export function ProfileCardFeedLoadingPlaceholder() { export function FeedLoadingPlaceholder({ style, + showLowerPlaceholder = true, + showTopBorder = true, }: { style?: StyleProp<ViewStyle> + showTopBorder?: boolean + showLowerPlaceholder?: boolean }) { const pal = usePalette('default') return ( <View style={[ - {paddingHorizontal: 12, paddingVertical: 18, borderTopWidth: 1}, + { + paddingHorizontal: 12, + paddingVertical: 18, + borderTopWidth: showTopBorder ? 1 : 0, + }, pal.border, style, ]}> @@ -193,14 +201,16 @@ export function FeedLoadingPlaceholder({ <LoadingPlaceholder width={120} height={8} /> </View> </View> - <View style={{paddingHorizontal: 5}}> - <LoadingPlaceholder - width={260} - height={8} - style={{marginVertical: 12}} - /> - <LoadingPlaceholder width={120} height={8} /> - </View> + {showLowerPlaceholder && ( + <View style={{paddingHorizontal: 5}}> + <LoadingPlaceholder + width={260} + height={8} + style={{marginVertical: 12}} + /> + <LoadingPlaceholder width={120} height={8} /> + </View> + )} </View> ) } diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index c5e438f8d..fa5f12f6b 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -6,7 +6,6 @@ import {niceDate} from 'lib/strings/time' import {usePalette} from 'lib/hooks/usePalette' import {TypographyVariant} from 'lib/ThemeContext' import {UserAvatar} from './UserAvatar' -import {observer} from 'mobx-react-lite' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {isAndroid} from 'platform/detection' @@ -30,7 +29,7 @@ interface PostMetaOpts { style?: StyleProp<ViewStyle> } -export const PostMeta = observer(function PostMetaImpl(opts: PostMetaOpts) { +export function PostMeta(opts: PostMetaOpts) { const pal = usePalette('default') const displayName = opts.author.displayName || opts.author.handle const handle = opts.author.handle @@ -92,7 +91,7 @@ export const PostMeta = observer(function PostMetaImpl(opts: PostMetaOpts) { </TimeElapsed> </View> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/util/PostSandboxWarning.tsx b/src/view/com/util/PostSandboxWarning.tsx index 21f5f7b90..b2375c703 100644 --- a/src/view/com/util/PostSandboxWarning.tsx +++ b/src/view/com/util/PostSandboxWarning.tsx @@ -1,13 +1,13 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {Text} from './text/Text' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' +import {useSession} from '#/state/session' export function PostSandboxWarning() { - const store = useStores() + const {isSandbox} = useSession() const pal = usePalette('default') - if (store.session.isSandbox) { + if (isSandbox) { return ( <View style={styles.container}> <Text diff --git a/src/view/com/util/SimpleViewHeader.tsx b/src/view/com/util/SimpleViewHeader.tsx index c871d9404..e86e37565 100644 --- a/src/view/com/util/SimpleViewHeader.tsx +++ b/src/view/com/util/SimpleViewHeader.tsx @@ -1,5 +1,4 @@ import React from 'react' -import {observer} from 'mobx-react-lite' import { StyleProp, StyleSheet, @@ -18,7 +17,7 @@ import {useSetDrawerOpen} from '#/state/shell' const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} -export const SimpleViewHeader = observer(function SimpleViewHeaderImpl({ +export function SimpleViewHeader({ showBackButton = true, style, children, @@ -76,7 +75,7 @@ export const SimpleViewHeader = observer(function SimpleViewHeaderImpl({ {children} </Container> ) -}) +} const styles = StyleSheet.create({ header: { diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx index 0765f65b2..aa3a09223 100644 --- a/src/view/com/util/TimeElapsed.tsx +++ b/src/view/com/util/TimeElapsed.tsx @@ -1,24 +1,22 @@ import React from 'react' -import {observer} from 'mobx-react-lite' import {ago} from 'lib/strings/time' -import {useStores} from 'state/index' +import {useTickEveryMinute} from '#/state/shell' // FIXME(dan): Figure out why the false positives -/* eslint-disable react/prop-types */ -export const TimeElapsed = observer(function TimeElapsed({ +export function TimeElapsed({ timestamp, children, }: { timestamp: string children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element }) { - const stores = useStores() + const tick = useTickEveryMinute() const [timeElapsed, setTimeAgo] = React.useState(ago(timestamp)) React.useEffect(() => { setTimeAgo(ago(timestamp)) - }, [timestamp, setTimeAgo, stores.shell.tickEveryMinute]) + }, [timestamp, setTimeAgo, tick]) return children({timeElapsed}) -}) +} diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index 4c9045d1e..c7134febe 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -1,6 +1,7 @@ import RootSiblings from 'react-native-root-siblings' import React from 'react' import {Animated, StyleSheet, View} from 'react-native' +import {Props as FontAwesomeProps} from '@fortawesome/react-native-fontawesome' import {Text} from './text/Text' import {colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' @@ -9,7 +10,10 @@ import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' const TIMEOUT = 4e3 -export function show(message: string) { +export function show( + message: string, + _icon: FontAwesomeProps['icon'] = 'check', +) { const item = new RootSiblings(<Toast message={message} />) setTimeout(() => { item.destroy() diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx index c295bad69..beb67c30c 100644 --- a/src/view/com/util/Toast.web.tsx +++ b/src/view/com/util/Toast.web.tsx @@ -7,12 +7,14 @@ import {StyleSheet, Text, View} from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, + Props as FontAwesomeProps, } from '@fortawesome/react-native-fontawesome' const DURATION = 3500 interface ActiveToast { text: string + icon: FontAwesomeProps['icon'] } type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void @@ -36,7 +38,7 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => { {activeToast && ( <View style={styles.container}> <FontAwesomeIcon - icon="check" + icon={activeToast.icon} size={24} style={styles.icon as FontAwesomeIconStyle} /> @@ -49,11 +51,12 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => { // methods // = -export function show(text: string) { + +export function show(text: string, icon: FontAwesomeProps['icon'] = 'check') { if (toastTimeout) { clearTimeout(toastTimeout) } - globalSetActiveToast?.({text}) + globalSetActiveToast?.({text, icon}) toastTimeout = setTimeout(() => { globalSetActiveToast?.(undefined) }, DURATION) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 9db457325..395e9eb3a 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -9,13 +9,14 @@ import { usePhotoLibraryPermission, useCameraPermission, } from 'lib/hooks/usePermissions' -import {useStores} from 'state/index' import {colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb, isAndroid} from 'platform/detection' import {Image as RNImage} from 'react-native-image-crop-picker' import {UserPreviewLink} from './UserPreviewLink' import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' export type UserAvatarType = 'user' | 'algo' | 'list' @@ -42,7 +43,13 @@ interface PreviewableUserAvatarProps extends BaseUserAvatarProps { const BLUR_AMOUNT = isWeb ? 5 : 100 -function DefaultAvatar({type, size}: {type: UserAvatarType; size: number}) { +export function DefaultAvatar({ + type, + size, +}: { + type: UserAvatarType + size: number +}) { if (type === 'algo') { // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. return ( @@ -182,8 +189,8 @@ export function EditableUserAvatar({ avatar, onSelectNewAvatar, }: EditableUserAvatarProps) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() @@ -207,7 +214,7 @@ export function EditableUserAvatar({ [ !isWeb && { testID: 'changeAvatarCameraBtn', - label: 'Camera', + label: _(msg`Camera`), icon: { ios: { name: 'camera', @@ -221,7 +228,7 @@ export function EditableUserAvatar({ } onSelectNewAvatar( - await openCamera(store, { + await openCamera({ width: 1000, height: 1000, cropperCircleOverlay: true, @@ -231,7 +238,7 @@ export function EditableUserAvatar({ }, { testID: 'changeAvatarLibraryBtn', - label: 'Library', + label: _(msg`Library`), icon: { ios: { name: 'photo.on.rectangle.angled', @@ -252,7 +259,7 @@ export function EditableUserAvatar({ return } - const croppedImage = await openCropper(store, { + const croppedImage = await openCropper({ mediaType: 'photo', cropperCircleOverlay: true, height: item.height, @@ -268,7 +275,7 @@ export function EditableUserAvatar({ }, !!avatar && { testID: 'changeAvatarRemoveBtn', - label: 'Remove', + label: _(msg`Remove`), icon: { ios: { name: 'trash', @@ -286,7 +293,7 @@ export function EditableUserAvatar({ onSelectNewAvatar, requestCameraAccessIfNeeded, requestPhotoAccessIfNeeded, - store, + _, ], ) @@ -294,7 +301,7 @@ export function EditableUserAvatar({ <NativeDropdown testID="changeAvatarBtn" items={dropdownItems} - accessibilityLabel="Image options" + accessibilityLabel={_(msg`Image options`)} accessibilityHint=""> {avatar ? ( <HighPriorityImage diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 4bdfad06c..b31d7e551 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -5,7 +5,6 @@ import {ModerationUI} from '@atproto/api' import {Image} from 'expo-image' import {colors} from 'lib/styles' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' -import {useStores} from 'state/index' import { usePhotoLibraryPermission, useCameraPermission, @@ -14,6 +13,8 @@ import {usePalette} from 'lib/hooks/usePalette' import {isWeb, isAndroid} from 'platform/detection' import {Image as RNImage} from 'react-native-image-crop-picker' import {NativeDropdown, DropdownItem} from './forms/NativeDropdown' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' export function UserBanner({ banner, @@ -24,8 +25,8 @@ export function UserBanner({ moderation?: ModerationUI onSelectNewBanner?: (img: RNImage | null) => void }) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() @@ -34,7 +35,7 @@ export function UserBanner({ [ !isWeb && { testID: 'changeBannerCameraBtn', - label: 'Camera', + label: _(msg`Camera`), icon: { ios: { name: 'camera', @@ -47,7 +48,7 @@ export function UserBanner({ return } onSelectNewBanner?.( - await openCamera(store, { + await openCamera({ width: 3000, height: 1000, }), @@ -56,7 +57,7 @@ export function UserBanner({ }, { testID: 'changeBannerLibraryBtn', - label: 'Library', + label: _(msg`Library`), icon: { ios: { name: 'photo.on.rectangle.angled', @@ -74,7 +75,7 @@ export function UserBanner({ } onSelectNewBanner?.( - await openCropper(store, { + await openCropper({ mediaType: 'photo', path: items[0].path, width: 3000, @@ -85,7 +86,7 @@ export function UserBanner({ }, !!banner && { testID: 'changeBannerRemoveBtn', - label: 'Remove', + label: _(msg`Remove`), icon: { ios: { name: 'trash', @@ -103,7 +104,7 @@ export function UserBanner({ onSelectNewBanner, requestCameraAccessIfNeeded, requestPhotoAccessIfNeeded, - store, + _, ], ) @@ -112,7 +113,7 @@ export function UserBanner({ <NativeDropdown testID="changeBannerBtn" items={dropdownItems} - accessibilityLabel="Image options" + accessibilityLabel={_(msg`Image options`)} accessibilityHint=""> {banner ? ( <Image diff --git a/src/view/com/util/UserInfoText.tsx b/src/view/com/util/UserInfoText.tsx index e4ca981d9..e5d2ceb03 100644 --- a/src/view/com/util/UserInfoText.tsx +++ b/src/view/com/util/UserInfoText.tsx @@ -1,14 +1,14 @@ -import React, {useState, useEffect} from 'react' +import React from 'react' import {AppBskyActorGetProfile as GetProfile} from '@atproto/api' import {StyleProp, StyleSheet, TextStyle} from 'react-native' import {TextLinkOnWebOnly} from './Link' import {Text} from './text/Text' import {LoadingPlaceholder} from './LoadingPlaceholder' -import {useStores} from 'state/index' import {TypographyVariant} from 'lib/ThemeContext' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' +import {useProfileQuery} from '#/state/queries/profile' export function UserInfoText({ type = 'md', @@ -29,35 +29,10 @@ export function UserInfoText({ attr = attr || 'handle' failed = failed || 'user' - const store = useStores() - const [profile, setProfile] = useState<undefined | GetProfile.OutputSchema>( - undefined, - ) - const [didFail, setFailed] = useState<boolean>(false) - - useEffect(() => { - let aborted = false - store.profiles.getProfile(did).then( - v => { - if (aborted) { - return - } - setProfile(v.data) - }, - _err => { - if (aborted) { - return - } - setFailed(true) - }, - ) - return () => { - aborted = true - } - }, [did, store.profiles]) + const {data: profile, isError} = useProfileQuery({did}) let inner - if (didFail) { + if (isError) { inner = ( <Text type={type} style={style} numberOfLines={1}> {failed} diff --git a/src/view/com/util/UserPreviewLink.tsx b/src/view/com/util/UserPreviewLink.tsx index f43f9e80b..9c5efe55e 100644 --- a/src/view/com/util/UserPreviewLink.tsx +++ b/src/view/com/util/UserPreviewLink.tsx @@ -1,9 +1,9 @@ import React from 'react' import {Pressable, StyleProp, ViewStyle} from 'react-native' -import {useStores} from 'state/index' import {Link} from './Link' import {isWeb} from 'platform/detection' import {makeProfileLink} from 'lib/routes/links' +import {useModalControls} from '#/state/modals' interface UserPreviewLinkProps { did: string @@ -13,7 +13,7 @@ interface UserPreviewLinkProps { export function UserPreviewLink( props: React.PropsWithChildren<UserPreviewLinkProps>, ) { - const store = useStores() + const {openModal} = useModalControls() if (isWeb) { return ( @@ -29,7 +29,7 @@ export function UserPreviewLink( return ( <Pressable onPress={() => - store.shell.openModal({ + openModal({ name: 'profile-preview', did: props.did, }) diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index adf2e4f08..082cae59c 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -1,5 +1,4 @@ import React from 'react' -import {observer} from 'mobx-react-lite' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' @@ -15,7 +14,7 @@ import {useSetDrawerOpen} from '#/state/shell' const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} -export const ViewHeader = observer(function ViewHeaderImpl({ +export function ViewHeader({ title, canGoBack, showBackButton = true, @@ -108,7 +107,7 @@ export const ViewHeader = observer(function ViewHeaderImpl({ </Container> ) } -}) +} function DesktopWebHeader({ title, @@ -140,7 +139,7 @@ function DesktopWebHeader({ ) } -const Container = observer(function ContainerImpl({ +function Container({ children, hideOnScroll, showBorder, @@ -178,7 +177,7 @@ const Container = observer(function ContainerImpl({ {children} </Animated.View> ) -}) +} const styles = StyleSheet.create({ header: { diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx index 1c2edc0cc..5a4f266fd 100644 --- a/src/view/com/util/Views.web.tsx +++ b/src/view/com/util/Views.web.tsx @@ -108,9 +108,9 @@ export const FlatList = React.forwardRef(function FlatListImpl<ItemT>( <Animated.FlatList ref={ref} contentContainerStyle={[ + styles.contentContainer, contentContainerStyle, pal.border, - styles.contentContainer, ]} style={style} contentOffset={contentOffset} @@ -135,9 +135,9 @@ export const ScrollView = React.forwardRef(function ScrollViewImpl( return ( <Animated.ScrollView contentContainerStyle={[ + styles.contentContainer, contentContainerStyle, pal.border, - styles.contentContainer, ]} // @ts-ignore something is wrong with the reanimated types -prf ref={ref} diff --git a/src/view/com/util/error/ErrorMessage.tsx b/src/view/com/util/error/ErrorMessage.tsx index 370f10ae3..b4adbb557 100644 --- a/src/view/com/util/error/ErrorMessage.tsx +++ b/src/view/com/util/error/ErrorMessage.tsx @@ -13,6 +13,8 @@ import { import {Text} from '../text/Text' import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' export function ErrorMessage({ message, @@ -27,6 +29,7 @@ export function ErrorMessage({ }) { const theme = useTheme() const pal = usePalette('error') + const {_} = useLingui() return ( <View testID="errorMessageView" style={[styles.outer, pal.view, style]}> <View @@ -49,7 +52,7 @@ export function ErrorMessage({ style={styles.btn} onPress={onPressTryAgain} accessibilityRole="button" - accessibilityLabel="Retry" + accessibilityLabel={_(msg`Retry`)} accessibilityHint="Retries the last action, which errored out"> <FontAwesomeIcon icon="arrows-rotate" diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx index a5deeb18f..4cd6dd4b4 100644 --- a/src/view/com/util/error/ErrorScreen.tsx +++ b/src/view/com/util/error/ErrorScreen.tsx @@ -9,6 +9,8 @@ import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' import {Button} from '../forms/Button' import {CenteredView} from '../Views' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export function ErrorScreen({ title, @@ -25,6 +27,8 @@ export function ErrorScreen({ }) { const theme = useTheme() const pal = usePalette('default') + const {_} = useLingui() + return ( <CenteredView testID={testID} style={[styles.outer, pal.view]}> <View style={styles.errorIconContainer}> @@ -58,7 +62,7 @@ export function ErrorScreen({ type="default" style={[styles.btn]} onPress={onPressTryAgain} - accessibilityLabel="Retry" + accessibilityLabel={_(msg`Retry`)} accessibilityHint="Retries the last action, which errored out"> <FontAwesomeIcon icon="arrows-rotate" @@ -66,7 +70,7 @@ export function ErrorScreen({ size={16} /> <Text type="button" style={[styles.btnText, pal.link]}> - Try again + <Trans>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 5b1d5d888..9787d92fb 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -1,5 +1,4 @@ import React, {ComponentProps} from 'react' -import {observer} from 'mobx-react-lite' import {StyleSheet, TouchableWithoutFeedback} from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {gradients} from 'lib/styles' @@ -15,11 +14,7 @@ export interface FABProps icon: JSX.Element } -export const FABInner = observer(function FABInnerImpl({ - testID, - icon, - ...props -}: FABProps) { +export function FABInner({testID, icon, ...props}: FABProps) { const insets = useSafeAreaInsets() const {isMobile, isTablet} = useWebMediaQueries() const {fabMinimalShellTransform} = useMinimalShellMode() @@ -55,7 +50,7 @@ export const FABInner = observer(function FABInnerImpl({ </Animated.View> </TouchableWithoutFeedback> ) -}) +} const styles = StyleSheet.create({ sizeRegular: { diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index 270d98317..8f24f8288 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -52,6 +52,7 @@ export function Button({ accessibilityLabelledBy, onAccessibilityEscape, withLoading = false, + disabled = false, }: React.PropsWithChildren<{ type?: ButtonType label?: string @@ -65,6 +66,7 @@ export function Button({ accessibilityLabelledBy?: string onAccessibilityEscape?: () => void withLoading?: boolean + disabled?: boolean }>) { const theme = useTheme() const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>( @@ -198,7 +200,7 @@ export function Button({ <Pressable style={getStyle} onPress={onPressWrapped} - disabled={isLoading} + disabled={disabled || isLoading} testID={testID} accessibilityRole="button" accessibilityLabel={accessibilityLabel} diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index 1bed60b5d..ad8f50f5e 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -17,6 +17,8 @@ import {colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {HITSLOP_10} from 'lib/constants' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' const ESTIMATED_BTN_HEIGHT = 50 const ESTIMATED_SEP_HEIGHT = 16 @@ -207,6 +209,7 @@ const DropdownItems = ({ }: DropDownItemProps) => { const pal = usePalette('default') const theme = useTheme() + const {_} = useLingui() const dropDownBackgroundColor = theme.colorScheme === 'dark' ? pal.btn : pal.view const separatorColor = @@ -224,7 +227,7 @@ const DropdownItems = ({ {/* This TouchableWithoutFeedback renders the background so if the user clicks outside, the dropdown closes */} <TouchableWithoutFeedback onPress={onOuterPress} - accessibilityLabel="Toggle dropdown" + accessibilityLabel={_(msg`Toggle dropdown`)} accessibilityHint=""> <View style={[styles.bg]} /> </TouchableWithoutFeedback> diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 1fffa3123..1ba5ae8ae 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -1,49 +1,101 @@ import React from 'react' -import {StyleProp, View, ViewStyle} from 'react-native' +import {Linking, StyleProp, View, ViewStyle} from 'react-native' +import Clipboard from '@react-native-clipboard/clipboard' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api' import {toShareUrl} from 'lib/strings/url-helpers' -import {useStores} from 'state/index' import {useTheme} from 'lib/ThemeContext' import {shareUrl} from 'lib/sharing' import { NativeDropdown, DropdownItem as NativeDropdownItem, } from './NativeDropdown' +import * as Toast from '../Toast' import {EventStopper} from '../EventStopper' +import {useModalControls} from '#/state/modals' +import {makeProfileLink} from '#/lib/routes/links' +import {getTranslatorLink} from '#/locale/helpers' +import {usePostDeleteMutation} from '#/state/queries/post' +import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' +import {useLanguagePrefs} from '#/state/preferences' +import {logger} from '#/logger' +import {Shadow} from '#/state/cache/types' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSession} from '#/state/session' +import {isWeb} from '#/platform/detection' export function PostDropdownBtn({ testID, - itemUri, - itemCid, - itemHref, - isAuthor, - isThreadMuted, - onCopyPostText, - onOpenTranslate, - onToggleThreadMute, - onDeletePost, + post, + record, style, }: { testID: string - itemUri: string - itemCid: string - itemHref: string - itemTitle: string - isAuthor: boolean - isThreadMuted: boolean - onCopyPostText: () => void - onOpenTranslate: () => void - onToggleThreadMute: () => void - onDeletePost: () => void + post: Shadow<AppBskyFeedDefs.PostView> + record: AppBskyFeedPost.Record style?: StyleProp<ViewStyle> }) { - const store = useStores() + const {hasSession, currentAccount} = useSession() const theme = useTheme() + const {_} = useLingui() const defaultCtrlColor = theme.palette.default.postCtrl + const {openModal} = useModalControls() + const langPrefs = useLanguagePrefs() + const mutedThreads = useMutedThreads() + const toggleThreadMute = useToggleThreadMute() + const postDeleteMutation = usePostDeleteMutation() + + const rootUri = record.reply?.root?.uri || post.uri + const isThreadMuted = mutedThreads.includes(rootUri) + const isAuthor = post.author.did === currentAccount?.did + const href = React.useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey) + }, [post.uri, post.author]) + + const translatorUrl = getTranslatorLink( + record.text, + langPrefs.primaryLanguage, + ) + + const onDeletePost = React.useCallback(() => { + postDeleteMutation.mutateAsync({uri: post.uri}).then( + () => { + Toast.show('Post deleted') + }, + e => { + logger.error('Failed to delete post', {error: e}) + Toast.show('Failed to delete post, please try again') + }, + ) + }, [post, postDeleteMutation]) + + const onToggleThreadMute = React.useCallback(() => { + try { + const muted = toggleThreadMute(rootUri) + if (muted) { + Toast.show('You will no longer receive notifications for this thread') + } else { + Toast.show('You will now receive notifications for this thread') + } + } catch (e) { + logger.error('Failed to toggle thread mute', {error: e}) + } + }, [rootUri, toggleThreadMute]) + + const onCopyPostText = React.useCallback(() => { + Clipboard.setString(record?.text || '') + Toast.show('Copied to clipboard') + }, [record]) + + const onOpenTranslate = React.useCallback(() => { + Linking.openURL(translatorUrl) + }, [translatorUrl]) const dropdownItems: NativeDropdownItem[] = [ { - label: 'Translate', + label: _(msg`Translate`), onPress() { onOpenTranslate() }, @@ -57,7 +109,7 @@ export function PostDropdownBtn({ }, }, { - label: 'Copy post text', + label: _(msg`Copy post text`), onPress() { onCopyPostText() }, @@ -71,9 +123,9 @@ export function PostDropdownBtn({ }, }, { - label: 'Share', + label: isWeb ? _(msg`Copy link to post`) : _(msg`Share`), onPress() { - const url = toShareUrl(itemHref) + const url = toShareUrl(href) shareUrl(url) }, testID: 'postDropdownShareBtn', @@ -85,11 +137,11 @@ export function PostDropdownBtn({ web: 'share', }, }, - { + hasSession && { label: 'separator', }, - { - label: isThreadMuted ? 'Unmute thread' : 'Mute thread', + hasSession && { + label: isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`), onPress() { onToggleThreadMute() }, @@ -102,37 +154,38 @@ export function PostDropdownBtn({ web: 'comment-slash', }, }, - { + hasSession && { label: 'separator', }, - !isAuthor && { - label: 'Report post', - onPress() { - store.shell.openModal({ - name: 'report', - uri: itemUri, - cid: itemCid, - }) - }, - testID: 'postDropdownReportBtn', - icon: { - ios: { - name: 'exclamationmark.triangle', + !isAuthor && + hasSession && { + label: _(msg`Report post`), + onPress() { + openModal({ + name: 'report', + uri: post.uri, + cid: post.cid, + }) + }, + testID: 'postDropdownReportBtn', + icon: { + ios: { + name: 'exclamationmark.triangle', + }, + android: 'ic_menu_report_image', + web: 'circle-exclamation', }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', }, - }, isAuthor && { label: 'separator', }, isAuthor && { - label: 'Delete post', + label: _(msg`Delete post`), onPress() { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Delete this post?', - message: 'Are you sure? This can not be undone.', + title: _(msg`Delete this post?`), + message: _(msg`Are you sure? This cannot be undone.`), onPressConfirm: onDeletePost, }) }, diff --git a/src/view/com/util/forms/SearchInput.tsx b/src/view/com/util/forms/SearchInput.tsx index c1eb82bd4..02b462b55 100644 --- a/src/view/com/util/forms/SearchInput.tsx +++ b/src/view/com/util/forms/SearchInput.tsx @@ -14,6 +14,8 @@ import { import {MagnifyingGlassIcon} from 'lib/icons' import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' interface Props { query: string @@ -33,6 +35,7 @@ export function SearchInput({ }: Props) { const theme = useTheme() const pal = usePalette('default') + const {_} = useLingui() const textInput = React.useRef<TextInput>(null) const onPressCancelSearchInner = React.useCallback(() => { @@ -58,7 +61,7 @@ export function SearchInput({ onChangeText={onChangeQuery} onSubmitEditing={onSubmitQuery} accessibilityRole="search" - accessibilityLabel="Search" + accessibilityLabel={_(msg`Search`)} accessibilityHint="" autoCorrect={false} autoCapitalize="none" @@ -67,7 +70,7 @@ export function SearchInput({ <TouchableOpacity onPress={onPressCancelSearchInner} accessibilityRole="button" - accessibilityLabel="Clear search query" + accessibilityLabel={_(msg`Clear search query`)} accessibilityHint=""> <FontAwesomeIcon icon="xmark" diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 6cbcddc32..b5b6c1b52 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -2,8 +2,8 @@ import React from 'react' import {StyleProp, StyleSheet, Pressable, View, ViewStyle} from 'react-native' import {Image} from 'expo-image' import {clamp} from 'lib/numbers' -import {useStores} from 'state/index' import {Dimensions} from 'lib/media/types' +import * as imageSizes from 'lib/media/image-sizes' const MIN_ASPECT_RATIO = 0.33 // 1/3 const MAX_ASPECT_RATIO = 5 // 5/1 @@ -29,9 +29,8 @@ export function AutoSizedImage({ style, children = null, }: Props) { - const store = useStores() const [dim, setDim] = React.useState<Dimensions | undefined>( - dimensionsHint || store.imageSizes.get(uri), + dimensionsHint || imageSizes.get(uri), ) const [aspectRatio, setAspectRatio] = React.useState<number>( dim ? calc(dim) : 1, @@ -41,14 +40,14 @@ export function AutoSizedImage({ if (dim) { return } - store.imageSizes.fetch(uri).then(newDim => { + imageSizes.fetch(uri).then(newDim => { if (aborted) { return } setDim(newDim) setAspectRatio(calc(newDim)) }) - }, [dim, setDim, setAspectRatio, store, uri]) + }, [dim, setDim, setAspectRatio, uri]) if (onPress || onLongPress || onPressIn) { return ( diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index 4aa6f28de..23e807b6a 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -69,12 +69,12 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { <GalleryItem {...props} index={0} imageStyle={styles.image} /> </View> <View style={styles.smallItem}> - <GalleryItem {...props} index={2} imageStyle={styles.image} /> + <GalleryItem {...props} index={1} imageStyle={styles.image} /> </View> </View> <View style={styles.flexRow}> <View style={styles.smallItem}> - <GalleryItem {...props} index={1} imageStyle={styles.image} /> + <GalleryItem {...props} index={2} imageStyle={styles.image} /> </View> <View style={styles.smallItem}> <GalleryItem {...props} index={3} imageStyle={styles.image} /> diff --git a/src/view/com/util/layouts/Breakpoints.web.tsx b/src/view/com/util/layouts/Breakpoints.web.tsx index 5cf73df0c..5106e3e1f 100644 --- a/src/view/com/util/layouts/Breakpoints.web.tsx +++ b/src/view/com/util/layouts/Breakpoints.web.tsx @@ -8,13 +8,13 @@ export const TabletOrDesktop = ({children}: React.PropsWithChildren<{}>) => ( <MediaQuery minWidth={800}>{children}</MediaQuery> ) export const Tablet = ({children}: React.PropsWithChildren<{}>) => ( - <MediaQuery minWidth={800} maxWidth={1300}> + <MediaQuery minWidth={800} maxWidth={1300 - 1}> {children} </MediaQuery> ) export const TabletOrMobile = ({children}: React.PropsWithChildren<{}>) => ( - <MediaQuery maxWidth={1300}>{children}</MediaQuery> + <MediaQuery maxWidth={1300 - 1}>{children}</MediaQuery> ) export const Mobile = ({children}: React.PropsWithChildren<{}>) => ( - <MediaQuery maxWidth={800}>{children}</MediaQuery> + <MediaQuery maxWidth={800 - 1}>{children}</MediaQuery> ) diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx index f9a9387bb..970d3a73a 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx @@ -1,6 +1,5 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' @@ -12,7 +11,7 @@ const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity) import {isWeb} from 'platform/detection' -export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ +export function LoadLatestBtn({ onPress, label, showIndicator, @@ -44,7 +43,7 @@ export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ {showIndicator && <View style={[styles.indicator, pal.borderDark]} />} </AnimatedTouchableOpacity> ) -}) +} const styles = StyleSheet.create({ loadLatest: { diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx index 4f917844a..a13aae2b5 100644 --- a/src/view/com/util/moderation/ContentHider.tsx +++ b/src/view/com/util/moderation/ContentHider.tsx @@ -6,7 +6,9 @@ import {ModerationUI} from '@atproto/api' import {Text} from '../text/Text' import {ShieldExclamation} from 'lib/icons' import {describeModerationCause} from 'lib/moderation' -import {useStores} from 'state/index' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {useModalControls} from '#/state/modals' export function ContentHider({ testID, @@ -22,10 +24,11 @@ export function ContentHider({ style?: StyleProp<ViewStyle> childContainerStyle?: StyleProp<ViewStyle> }>) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() const {isMobile} = useWebMediaQueries() const [override, setOverride] = React.useState(false) + const {openModal} = useModalControls() if (!moderation.blur || (ignoreMute && moderation.cause?.type === 'muted')) { return ( @@ -43,7 +46,7 @@ export function ContentHider({ if (!moderation.noOverride) { setOverride(v => !v) } else { - store.shell.openModal({ + openModal({ name: 'moderation-details', context: 'content', moderation, @@ -62,14 +65,14 @@ export function ContentHider({ ]}> <Pressable onPress={() => { - store.shell.openModal({ + openModal({ name: 'moderation-details', context: 'content', moderation, }) }} accessibilityRole="button" - accessibilityLabel="Learn more about this warning" + accessibilityLabel={_(msg`Learn more about this warning`)} accessibilityHint=""> <ShieldExclamation size={18} style={pal.text} /> </Pressable> diff --git a/src/view/com/util/moderation/PostAlerts.tsx b/src/view/com/util/moderation/PostAlerts.tsx index 0dba367fc..bc5bf9b32 100644 --- a/src/view/com/util/moderation/PostAlerts.tsx +++ b/src/view/com/util/moderation/PostAlerts.tsx @@ -5,7 +5,9 @@ import {Text} from '../text/Text' import {usePalette} from 'lib/hooks/usePalette' import {ShieldExclamation} from 'lib/icons' import {describeModerationCause} from 'lib/moderation' -import {useStores} from 'state/index' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export function PostAlerts({ moderation, @@ -15,8 +17,9 @@ export function PostAlerts({ includeMute?: boolean style?: StyleProp<ViewStyle> }) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() + const {openModal} = useModalControls() const shouldAlert = !!moderation.cause && moderation.alert if (!shouldAlert) { @@ -27,21 +30,21 @@ export function PostAlerts({ return ( <Pressable onPress={() => { - store.shell.openModal({ + openModal({ name: 'moderation-details', context: 'content', moderation, }) }} accessibilityRole="button" - accessibilityLabel="Learn more about this warning" + accessibilityLabel={_(msg`Learn more about this warning`)} accessibilityHint="" style={[styles.container, pal.viewLight, style]}> <ShieldExclamation style={pal.text} size={16} /> <Text type="lg" style={[pal.text]}> {desc.name}{' '} <Text type="lg" style={[pal.link, styles.learnMoreBtn]}> - Learn More + <Trans>Learn More</Trans> </Text> </Text> </Pressable> diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx index d224286b0..c2b857f54 100644 --- a/src/view/com/util/moderation/PostHider.tsx +++ b/src/view/com/util/moderation/PostHider.tsx @@ -8,7 +8,9 @@ import {Text} from '../text/Text' import {addStyle} from 'lib/styles' import {describeModerationCause} from 'lib/moderation' import {ShieldExclamation} from 'lib/icons' -import {useStores} from 'state/index' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {useModalControls} from '#/state/modals' interface Props extends ComponentProps<typeof Link> { // testID?: string @@ -25,10 +27,11 @@ export function PostHider({ children, ...props }: Props) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() const {isMobile} = useWebMediaQueries() const [override, setOverride] = React.useState(false) + const {openModal} = useModalControls() if (!moderation.blur) { return ( @@ -63,14 +66,14 @@ export function PostHider({ ]}> <Pressable onPress={() => { - store.shell.openModal({ + openModal({ name: 'moderation-details', context: 'content', moderation, }) }} accessibilityRole="button" - accessibilityLabel="Learn more about this warning" + accessibilityLabel={_(msg`Learn more about this warning`)} accessibilityHint=""> <ShieldExclamation size={18} style={pal.text} /> </Pressable> diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx index 6b7f4e7ec..d2675ca54 100644 --- a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx +++ b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx @@ -8,7 +8,9 @@ import { describeModerationCause, getProfileModerationCauses, } from 'lib/moderation' -import {useStores} from 'state/index' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export function ProfileHeaderAlerts({ moderation, @@ -17,8 +19,9 @@ export function ProfileHeaderAlerts({ moderation: ProfileModeration style?: StyleProp<ViewStyle> }) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() + const {openModal} = useModalControls() const causes = getProfileModerationCauses(moderation) if (!causes.length) { @@ -34,14 +37,14 @@ export function ProfileHeaderAlerts({ testID="profileHeaderAlert" key={desc.name} onPress={() => { - store.shell.openModal({ + openModal({ name: 'moderation-details', context: 'content', moderation: {cause}, }) }} accessibilityRole="button" - accessibilityLabel="Learn more about this warning" + accessibilityLabel={_(msg`Learn more about this warning`)} accessibilityHint="" style={[styles.container, pal.viewLight, style]}> <ShieldExclamation style={pal.text} size={24} /> @@ -49,7 +52,7 @@ export function ProfileHeaderAlerts({ {desc.name} </Text> <Text type="lg" style={[pal.link, styles.learnMoreBtn]}> - Learn More + <Trans>Learn More</Trans> </Text> </Pressable> ) diff --git a/src/view/com/util/moderation/ScreenHider.tsx b/src/view/com/util/moderation/ScreenHider.tsx index 0224b9fee..946f937e9 100644 --- a/src/view/com/util/moderation/ScreenHider.tsx +++ b/src/view/com/util/moderation/ScreenHider.tsx @@ -18,7 +18,10 @@ import {NavigationProp} from 'lib/routes/types' import {Text} from '../text/Text' import {Button} from '../forms/Button' import {describeModerationCause} from 'lib/moderation' -import {useStores} from 'state/index' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {s} from '#/lib/styles' export function ScreenHider({ testID, @@ -34,12 +37,13 @@ export function ScreenHider({ style?: StyleProp<ViewStyle> containerStyle?: StyleProp<ViewStyle> }>) { - const store = useStores() const pal = usePalette('default') const palInverted = usePalette('inverted') + const {_} = useLingui() const [override, setOverride] = React.useState(false) const navigation = useNavigation<NavigationProp>() const {isMobile} = useWebMediaQueries() + const {openModal} = useModalControls() if (!moderation.blur || override) { return ( @@ -62,27 +66,26 @@ export function ScreenHider({ </View> </View> <Text type="title-2xl" style={[styles.title, pal.text]}> - Content Warning + <Trans>Content Warning</Trans> </Text> <Text type="2xl" style={[styles.description, pal.textLight]}> - This {screenDescription} has been flagged:{' '} - <Text type="2xl-medium" style={pal.text}> - {desc.name} + <Trans>This {screenDescription} has been flagged:</Trans> + <Text type="2xl-medium" style={[pal.text, s.ml5]}> + {desc.name}. </Text> - .{' '} <TouchableWithoutFeedback onPress={() => { - store.shell.openModal({ + openModal({ name: 'moderation-details', context: 'account', moderation, }) }} accessibilityRole="button" - accessibilityLabel="Learn more about this warning" + accessibilityLabel={_(msg`Learn more about this warning`)} accessibilityHint=""> <Text type="2xl" style={pal.link}> - Learn More + <Trans>Learn More</Trans> </Text> </TouchableWithoutFeedback> </Text> @@ -99,7 +102,7 @@ export function ScreenHider({ }} style={styles.btn}> <Text type="button-lg" style={pal.textInverted}> - Go back + <Trans>Go back</Trans> </Text> </Button> {!moderation.noOverride && ( @@ -108,7 +111,7 @@ export function ScreenHider({ onPress={() => setOverride(v => !v)} style={styles.btn}> <Text type="button-lg" style={pal.text}> - Show anyway + <Trans>Show anyway</Trans> </Text> </Button> )} diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 5769a478b..e548c45f7 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -6,168 +6,174 @@ import { View, ViewStyle, } from 'react-native' +import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api' import {Text} from '../text/Text' import {PostDropdownBtn} from '../forms/PostDropdownBtn' import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons' import {s, colors} from 'lib/styles' import {pluralize} from 'lib/strings/helpers' import {useTheme} from 'lib/ThemeContext' -import {useStores} from 'state/index' import {RepostButton} from './RepostButton' import {Haptics} from 'lib/haptics' import {HITSLOP_10, HITSLOP_20} from 'lib/constants' +import {useModalControls} from '#/state/modals' +import { + usePostLikeMutation, + usePostUnlikeMutation, + usePostRepostMutation, + usePostUnrepostMutation, +} from '#/state/queries/post' +import {useComposerControls} from '#/state/shell/composer' +import {Shadow} from '#/state/cache/types' +import {useRequireAuth} from '#/state/session' -interface PostCtrlsOpts { - itemUri: string - itemCid: string - itemHref: string - itemTitle: string - isAuthor: boolean - author: { - did: string - handle: string - displayName?: string | undefined - avatar?: string | undefined - } - text: string - indexedAt: string +export function PostCtrls({ + big, + post, + record, + style, + onPressReply, +}: { big?: boolean + post: Shadow<AppBskyFeedDefs.PostView> + record: AppBskyFeedPost.Record style?: StyleProp<ViewStyle> - replyCount?: number - repostCount?: number - likeCount?: number - isReposted: boolean - isLiked: boolean - isThreadMuted: boolean onPressReply: () => void - onPressToggleRepost: () => Promise<void> - onPressToggleLike: () => Promise<void> - onCopyPostText: () => void - onOpenTranslate: () => void - onToggleThreadMute: () => void - onDeletePost: () => void -} - -export function PostCtrls(opts: PostCtrlsOpts) { - const store = useStores() +}) { const theme = useTheme() + const {openComposer} = useComposerControls() + const {closeModal} = useModalControls() + const postLikeMutation = usePostLikeMutation() + const postUnlikeMutation = usePostUnlikeMutation() + const postRepostMutation = usePostRepostMutation() + const postUnrepostMutation = usePostUnrepostMutation() + const requireAuth = useRequireAuth() + const defaultCtrlColor = React.useMemo( () => ({ color: theme.palette.default.postCtrl, }), [theme], ) as StyleProp<ViewStyle> + + const onPressToggleLike = React.useCallback(async () => { + if (!post.viewer?.like) { + Haptics.default() + postLikeMutation.mutate({ + uri: post.uri, + cid: post.cid, + likeCount: post.likeCount || 0, + }) + } else { + postUnlikeMutation.mutate({ + postUri: post.uri, + likeUri: post.viewer.like, + likeCount: post.likeCount || 0, + }) + } + }, [post, postLikeMutation, postUnlikeMutation]) + const onRepost = useCallback(() => { - store.shell.closeModal() - if (!opts.isReposted) { + closeModal() + if (!post.viewer?.repost) { Haptics.default() - opts.onPressToggleRepost().catch(_e => undefined) + postRepostMutation.mutate({ + uri: post.uri, + cid: post.cid, + repostCount: post.repostCount || 0, + }) } else { - opts.onPressToggleRepost().catch(_e => undefined) + postUnrepostMutation.mutate({ + postUri: post.uri, + repostUri: post.viewer.repost, + repostCount: post.repostCount || 0, + }) } - }, [opts, store.shell]) + }, [post, closeModal, postRepostMutation, postUnrepostMutation]) const onQuote = useCallback(() => { - store.shell.closeModal() - store.shell.openComposer({ + closeModal() + openComposer({ quote: { - uri: opts.itemUri, - cid: opts.itemCid, - text: opts.text, - author: opts.author, - indexedAt: opts.indexedAt, + uri: post.uri, + cid: post.cid, + text: record.text, + author: post.author, + indexedAt: post.indexedAt, }, }) Haptics.default() - }, [ - opts.author, - opts.indexedAt, - opts.itemCid, - opts.itemUri, - opts.text, - store.shell, - ]) - - const onPressToggleLikeWrapper = async () => { - if (!opts.isLiked) { - Haptics.default() - await opts.onPressToggleLike().catch(_e => undefined) - } else { - await opts.onPressToggleLike().catch(_e => undefined) - } - } - + }, [post, record, openComposer, closeModal]) return ( - <View style={[styles.ctrls, opts.style]}> + <View style={[styles.ctrls, style]}> <TouchableOpacity testID="replyBtn" - style={[styles.ctrl, !opts.big && styles.ctrlPad, {paddingLeft: 0}]} - onPress={opts.onPressReply} + style={[styles.ctrl, !big && styles.ctrlPad, {paddingLeft: 0}]} + onPress={() => { + requireAuth(() => onPressReply()) + }} accessibilityRole="button" - accessibilityLabel={`Reply (${opts.replyCount} ${ - opts.replyCount === 1 ? 'reply' : 'replies' + accessibilityLabel={`Reply (${post.replyCount} ${ + post.replyCount === 1 ? 'reply' : 'replies' })`} accessibilityHint="" - hitSlop={opts.big ? HITSLOP_20 : HITSLOP_10}> + hitSlop={big ? HITSLOP_20 : HITSLOP_10}> <CommentBottomArrow - style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]} + style={[defaultCtrlColor, big ? s.mt2 : styles.mt1]} strokeWidth={3} - size={opts.big ? 20 : 15} + size={big ? 20 : 15} /> - {typeof opts.replyCount !== 'undefined' ? ( + {typeof post.replyCount !== 'undefined' ? ( <Text style={[defaultCtrlColor, s.ml5, s.f15]}> - {opts.replyCount} + {post.replyCount} </Text> ) : undefined} </TouchableOpacity> - <RepostButton {...opts} onRepost={onRepost} onQuote={onQuote} /> + <RepostButton + big={big} + isReposted={!!post.viewer?.repost} + repostCount={post.repostCount} + onRepost={onRepost} + onQuote={onQuote} + /> <TouchableOpacity testID="likeBtn" - style={[styles.ctrl, !opts.big && styles.ctrlPad]} - onPress={onPressToggleLikeWrapper} + style={[styles.ctrl, !big && styles.ctrlPad]} + onPress={() => { + requireAuth(() => onPressToggleLike()) + }} accessibilityRole="button" - accessibilityLabel={`${opts.isLiked ? 'Unlike' : 'Like'} (${ - opts.likeCount - } ${pluralize(opts.likeCount || 0, 'like')})`} + accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${ + post.likeCount + } ${pluralize(post.likeCount || 0, 'like')})`} accessibilityHint="" - hitSlop={opts.big ? HITSLOP_20 : HITSLOP_10}> - {opts.isLiked ? ( - <HeartIconSolid - style={styles.ctrlIconLiked} - size={opts.big ? 22 : 16} - /> + hitSlop={big ? HITSLOP_20 : HITSLOP_10}> + {post.viewer?.like ? ( + <HeartIconSolid style={styles.ctrlIconLiked} size={big ? 22 : 16} /> ) : ( <HeartIcon - style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]} + style={[defaultCtrlColor, big ? styles.mt1 : undefined]} strokeWidth={3} - size={opts.big ? 20 : 16} + size={big ? 20 : 16} /> )} - {typeof opts.likeCount !== 'undefined' ? ( + {typeof post.likeCount !== 'undefined' ? ( <Text testID="likeCount" style={ - opts.isLiked + post.viewer?.like ? [s.bold, s.red3, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5] }> - {opts.likeCount} + {post.likeCount} </Text> ) : undefined} </TouchableOpacity> - {opts.big ? undefined : ( + {big ? undefined : ( <PostDropdownBtn testID="postDropdownBtn" - itemUri={opts.itemUri} - itemCid={opts.itemCid} - itemHref={opts.itemHref} - itemTitle={opts.itemTitle} - isAuthor={opts.isAuthor} - isThreadMuted={opts.isThreadMuted} - onCopyPostText={opts.onCopyPostText} - onOpenTranslate={opts.onOpenTranslate} - onToggleThreadMute={opts.onToggleThreadMute} - onDeletePost={opts.onDeletePost} + post={post} + record={record} style={styles.ctrlPad} /> )} diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index 9c4ed8e5d..1d34a88ab 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -5,8 +5,9 @@ import {s, colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' import {Text} from '../text/Text' import {pluralize} from 'lib/strings/helpers' -import {useStores} from 'state/index' import {HITSLOP_10, HITSLOP_20} from 'lib/constants' +import {useModalControls} from '#/state/modals' +import {useRequireAuth} from '#/state/session' interface Props { isReposted: boolean @@ -23,8 +24,9 @@ export const RepostButton = ({ onRepost, onQuote, }: Props) => { - const store = useStores() const theme = useTheme() + const {openModal} = useModalControls() + const requireAuth = useRequireAuth() const defaultControlColor = React.useMemo( () => ({ @@ -34,18 +36,20 @@ export const RepostButton = ({ ) const onPressToggleRepostWrapper = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'repost', onRepost: onRepost, onQuote: onQuote, isReposted, }) - }, [onRepost, onQuote, isReposted, store.shell]) + }, [onRepost, onQuote, isReposted, openModal]) return ( <TouchableOpacity testID="repostBtn" - onPress={onPressToggleRepostWrapper} + onPress={() => { + requireAuth(() => onPressToggleRepostWrapper()) + }} style={[styles.control, !big && styles.controlPad]} accessibilityRole="button" accessibilityLabel={`${ diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx index 57f544d41..329382132 100644 --- a/src/view/com/util/post-ctrls/RepostButton.web.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {StyleProp, StyleSheet, View, ViewStyle, Pressable} from 'react-native' import {RepostIcon} from 'lib/icons' import {colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' @@ -10,6 +10,10 @@ import { DropdownItem as NativeDropdownItem, } from '../forms/NativeDropdown' import {EventStopper} from '../EventStopper' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {useRequireAuth} from '#/state/session' +import {useSession} from '#/state/session' interface Props { isReposted: boolean @@ -28,6 +32,9 @@ export const RepostButton = ({ onQuote, }: Props) => { const theme = useTheme() + const {_} = useLingui() + const {hasSession} = useSession() + const requireAuth = useRequireAuth() const defaultControlColor = React.useMemo( () => ({ @@ -38,7 +45,7 @@ export const RepostButton = ({ const dropdownItems: NativeDropdownItem[] = [ { - label: isReposted ? 'Undo repost' : 'Repost', + label: isReposted ? _(msg`Undo repost`) : _(msg`Repost`), testID: 'repostDropdownRepostBtn', icon: { ios: {name: 'repeat'}, @@ -48,7 +55,7 @@ export const RepostButton = ({ onPress: onRepost, }, { - label: 'Quote post', + label: _(msg`Quote post`), testID: 'repostDropdownQuoteBtn', icon: { ios: {name: 'quote.bubble'}, @@ -59,32 +66,46 @@ export const RepostButton = ({ }, ] - return ( + const inner = ( + <View + style={[ + styles.control, + !big && styles.controlPad, + (isReposted + ? styles.reposted + : defaultControlColor) as StyleProp<ViewStyle>, + ]}> + <RepostIcon strokeWidth={2.2} size={big ? 24 : 20} /> + {typeof repostCount !== 'undefined' ? ( + <Text + testID="repostCount" + type={isReposted ? 'md-bold' : 'md'} + style={styles.repostCount}> + {repostCount ?? 0} + </Text> + ) : undefined} + </View> + ) + + return hasSession ? ( <EventStopper> <NativeDropdown items={dropdownItems} - accessibilityLabel="Repost or quote post" + accessibilityLabel={_(msg`Repost or quote post`)} accessibilityHint=""> - <View - style={[ - styles.control, - !big && styles.controlPad, - (isReposted - ? styles.reposted - : defaultControlColor) as StyleProp<ViewStyle>, - ]}> - <RepostIcon strokeWidth={2.2} size={big ? 24 : 20} /> - {typeof repostCount !== 'undefined' ? ( - <Text - testID="repostCount" - type={isReposted ? 'md-bold' : 'md'} - style={styles.repostCount}> - {repostCount ?? 0} - </Text> - ) : undefined} - </View> + {inner} </NativeDropdown> </EventStopper> + ) : ( + <Pressable + accessibilityRole="button" + onPress={() => { + requireAuth(() => {}) + }} + accessibilityLabel={_(msg`Repost or quote post`)} + accessibilityHint=""> + {inner} + </Pressable> ) } diff --git a/src/view/com/util/post-embeds/CustomFeedEmbed.tsx b/src/view/com/util/post-embeds/CustomFeedEmbed.tsx deleted file mode 100644 index 624157436..000000000 --- a/src/view/com/util/post-embeds/CustomFeedEmbed.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React, {useMemo} from 'react' -import {AppBskyFeedDefs} from '@atproto/api' -import {usePalette} from 'lib/hooks/usePalette' -import {StyleSheet} from 'react-native' -import {useStores} from 'state/index' -import {FeedSourceModel} from 'state/models/content/feed-source' -import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' - -export function CustomFeedEmbed({ - record, -}: { - record: AppBskyFeedDefs.GeneratorView -}) { - const pal = usePalette('default') - const store = useStores() - const item = useMemo(() => { - const model = new FeedSourceModel(store, record.uri) - model.hydrateFeedGenerator(record) - return model - }, [store, record]) - return ( - <FeedSourceCard - item={item} - style={[pal.view, pal.border, styles.customFeedOuter]} - showLikes - /> - ) -} - -const styles = StyleSheet.create({ - customFeedOuter: { - borderWidth: 1, - borderRadius: 8, - marginTop: 4, - paddingHorizontal: 12, - paddingVertical: 12, - }, -}) diff --git a/src/view/com/util/post-embeds/ListEmbed.tsx b/src/view/com/util/post-embeds/ListEmbed.tsx index dbf350039..fc5ad270f 100644 --- a/src/view/com/util/post-embeds/ListEmbed.tsx +++ b/src/view/com/util/post-embeds/ListEmbed.tsx @@ -1,12 +1,11 @@ import React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {usePalette} from 'lib/hooks/usePalette' -import {observer} from 'mobx-react-lite' import {ListCard} from 'view/com/lists/ListCard' import {AppBskyGraphDefs} from '@atproto/api' import {s} from 'lib/styles' -export const ListEmbed = observer(function ListEmbedImpl({ +export function ListEmbed({ item, style, }: { @@ -20,7 +19,7 @@ export const ListEmbed = observer(function ListEmbedImpl({ <ListCard list={item} style={[style, styles.card]} /> </View> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index f82b5b7df..e793f983e 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -12,7 +12,7 @@ import {PostMeta} from '../PostMeta' import {Link} from '../Link' import {Text} from '../text/Text' import {usePalette} from 'lib/hooks/usePalette' -import {ComposerOptsQuote} from 'state/models/ui/shell' +import {ComposerOptsQuote} from 'state/shell/composer' import {PostEmbeds} from '.' import {PostAlerts} from '../moderation/PostAlerts' import {makeProfileLink} from 'lib/routes/links' diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 6c13bc2bb..ca3bf1104 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -19,8 +19,7 @@ import { } from '@atproto/api' import {Link} from '../Link' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' -import {ImagesLightbox} from 'state/models/ui/shell' -import {useStores} from 'state/index' +import {useLightboxControls, ImagesLightbox} from '#/state/lightbox' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {YoutubeEmbed} from './YoutubeEmbed' @@ -28,9 +27,9 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {getYoutubeVideoId} from 'lib/strings/url-helpers' import {MaybeQuoteEmbed} from './QuoteEmbed' import {AutoSizedImage} from '../images/AutoSizedImage' -import {CustomFeedEmbed} from './CustomFeedEmbed' import {ListEmbed} from './ListEmbed' import {isCauseALabelOnUri} from 'lib/moderation' +import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' type Embed = | AppBskyEmbedRecord.View @@ -49,7 +48,7 @@ export function PostEmbeds({ style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') - const store = useStores() + const {openLightbox} = useLightboxControls() const {isMobile} = useWebMediaQueries() // quote post with media @@ -72,7 +71,13 @@ export function PostEmbeds({ // custom feed embed (i.e. generator view) // = if (AppBskyFeedDefs.isGeneratorView(embed.record)) { - return <CustomFeedEmbed record={embed.record} /> + return ( + <FeedSourceCard + feedUri={embed.record.uri} + style={[pal.view, pal.border, styles.customFeedOuter]} + showLikes + /> + ) } // list embed @@ -98,8 +103,8 @@ export function PostEmbeds({ alt: img.alt, aspectRatio: img.aspectRatio, })) - const openLightbox = (index: number) => { - store.shell.openLightbox(new ImagesLightbox(items, index)) + const _openLightbox = (index: number) => { + openLightbox(new ImagesLightbox(items, index)) } const onPressIn = (_: number) => { InteractionManager.runAfterInteractions(() => { @@ -115,7 +120,7 @@ export function PostEmbeds({ alt={alt} uri={thumb} dimensionsHint={aspectRatio} - onPress={() => openLightbox(0)} + onPress={() => _openLightbox(0)} onPressIn={() => onPressIn(0)} style={[ styles.singleImage, @@ -137,7 +142,7 @@ export function PostEmbeds({ <View style={[styles.imagesContainer, style]}> <ImageLayoutGrid images={embed.images} - onPress={openLightbox} + onPress={_openLightbox} onPressIn={onPressIn} style={ embed.images.length === 1 @@ -206,4 +211,11 @@ const styles = StyleSheet.create({ fontSize: 10, fontWeight: 'bold', }, + customFeedOuter: { + borderWidth: 1, + borderRadius: 8, + marginTop: 4, + paddingHorizontal: 12, + paddingVertical: 12, + }, }) |