diff options
Diffstat (limited to 'src/view')
201 files changed, 13045 insertions, 9682 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, + }, }) diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index 74d293ef4..154035f22 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -1,80 +1,114 @@ import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {ScrollView} from 'react-native-gesture-handler' import {Text} from '../com/util/text/Text' import {Button} from '../com/util/forms/Button' import * as Toast from '../com/util/Toast' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {CommonNavigatorParams} from 'lib/routes/types' import {useAnalytics} from 'lib/analytics/analytics' import {useFocusEffect} from '@react-navigation/native' import {ViewHeader} from '../com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {useLanguagePrefs} from '#/state/preferences' +import { + useAppPasswordsQuery, + useAppPasswordDeleteMutation, +} from '#/state/queries/app-passwords' +import {ErrorScreen} from '../com/util/error/ErrorScreen' +import {cleanError} from '#/lib/strings/errors' type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'> -export const AppPasswords = withAuthRequired( - observer(function AppPasswordsImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {screen} = useAnalytics() - const {isTabletOrDesktop} = useWebMediaQueries() +export function AppPasswords({}: Props) { + const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() + const {screen} = useAnalytics() + const {isTabletOrDesktop} = useWebMediaQueries() + const {openModal} = useModalControls() + const {data: appPasswords, error} = useAppPasswordsQuery() - useFocusEffect( - React.useCallback(() => { - screen('AppPasswords') - setMinimalShellMode(false) - }, [screen, setMinimalShellMode]), - ) + useFocusEffect( + React.useCallback(() => { + screen('AppPasswords') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) - const onAdd = React.useCallback(async () => { - store.shell.openModal({name: 'add-app-password'}) - }, [store]) + const onAdd = React.useCallback(async () => { + openModal({name: 'add-app-password'}) + }, [openModal]) - // no app passwords (empty) state - if (store.me.appPasswords.length === 0) { - return ( - <CenteredView - style={[ - styles.container, - isTabletOrDesktop && styles.containerDesktop, - pal.view, - pal.border, - ]} - testID="appPasswordsScreen"> - <AppPasswordsHeader /> - <View style={[styles.empty, pal.viewLight]}> - <Text type="lg" style={[pal.text, styles.emptyText]}> + if (error) { + return ( + <CenteredView + style={[ + styles.container, + isTabletOrDesktop && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="appPasswordsScreen"> + <ErrorScreen + title="Oops!" + message="There was an issue with fetching your app passwords" + details={cleanError(error)} + /> + </CenteredView> + ) + } + + // no app passwords (empty) state + if (appPasswords?.length === 0) { + return ( + <CenteredView + style={[ + styles.container, + isTabletOrDesktop && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="appPasswordsScreen"> + <AppPasswordsHeader /> + <View style={[styles.empty, pal.viewLight]}> + <Text type="lg" style={[pal.text, styles.emptyText]}> + <Trans> You have not created any app passwords yet. You can create one by pressing the button below. - </Text> - </View> - {!isTabletOrDesktop && <View style={styles.flex1} />} - <View - style={[ - styles.btnContainer, - isTabletOrDesktop && styles.btnContainerDesktop, - ]}> - <Button - testID="appPasswordBtn" - type="primary" - label="Add App Password" - style={styles.btn} - labelStyle={styles.btnLabel} - onPress={onAdd} - /> - </View> - </CenteredView> - ) - } + </Trans> + </Text> + </View> + {!isTabletOrDesktop && <View style={styles.flex1} />} + <View + style={[ + styles.btnContainer, + isTabletOrDesktop && styles.btnContainerDesktop, + ]}> + <Button + testID="appPasswordBtn" + type="primary" + label="Add App Password" + style={styles.btn} + labelStyle={styles.btnLabel} + onPress={onAdd} + /> + </View> + </CenteredView> + ) + } + if (appPasswords?.length) { // has app passwords return ( <CenteredView @@ -92,7 +126,7 @@ export const AppPasswords = withAuthRequired( pal.border, !isTabletOrDesktop && styles.flex1, ]}> - {store.me.appPasswords.map((password, i) => ( + {appPasswords.map((password, i) => ( <AppPassword key={password.name} testID={`appPassword-${i}`} @@ -127,15 +161,29 @@ export const AppPasswords = withAuthRequired( )} </CenteredView> ) - }), -) + } + + return ( + <CenteredView + style={[ + styles.container, + isTabletOrDesktop && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="appPasswordsScreen"> + <ActivityIndicator /> + </CenteredView> + ) +} function AppPasswordsHeader() { const {isTabletOrDesktop} = useWebMediaQueries() const pal = usePalette('default') + const {_} = useLingui() return ( <> - <ViewHeader title="App Passwords" showOnDesktop /> + <ViewHeader title={_(msg`App Passwords`)} showOnDesktop /> <Text type="sm" style={[ @@ -143,8 +191,10 @@ function AppPasswordsHeader() { pal.text, isTabletOrDesktop && styles.descriptionDesktop, ]}> - Use app passwords to login to other Bluesky clients without giving full - access to your account or password. + <Trans> + Use app passwords to login to other Bluesky clients without giving + full access to your account or password. + </Trans> </Text> </> ) @@ -160,21 +210,24 @@ function AppPassword({ createdAt: string }) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const {openModal} = useModalControls() + const {contentLanguages} = useLanguagePrefs() + const deleteMutation = useAppPasswordDeleteMutation() const onDelete = React.useCallback(async () => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Delete App Password', - message: `Are you sure you want to delete the app password "${name}"?`, + title: _(msg`Delete app password`), + message: _( + msg`Are you sure you want to delete the app password "${name}"?`, + ), async onPressConfirm() { - await store.me.deleteAppPassword(name) + await deleteMutation.mutateAsync({name}) Toast.show('App password deleted') }, }) - }, [store, name]) - - const {contentLanguages} = store.preferences + }, [deleteMutation, openModal, name, _]) const primaryLocale = contentLanguages.length > 0 ? contentLanguages[0] : 'en-US' @@ -185,22 +238,24 @@ function AppPassword({ style={[styles.item, pal.border]} onPress={onDelete} accessibilityRole="button" - accessibilityLabel="Delete app password" + accessibilityLabel={_(msg`Delete app password`)} accessibilityHint=""> <View> <Text type="md-bold" style={pal.text}> {name} </Text> <Text type="md" style={[pal.text, styles.pr10]} numberOfLines={1}> - Created{' '} - {Intl.DateTimeFormat(primaryLocale, { - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }).format(new Date(createdAt))} + <Trans> + Created{' '} + {Intl.DateTimeFormat(primaryLocale, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(new Date(createdAt))} + </Trans> </Text> </View> <FontAwesomeIcon icon={['far', 'trash-can']} style={styles.trashIcon} /> diff --git a/src/view/screens/CommunityGuidelines.tsx b/src/view/screens/CommunityGuidelines.tsx index 712172c3b..1931c6f13 100644 --- a/src/view/screens/CommunityGuidelines.tsx +++ b/src/view/screens/CommunityGuidelines.tsx @@ -9,6 +9,8 @@ import {ScrollView} from 'view/com/util/Views' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps< CommonNavigatorParams, @@ -16,6 +18,7 @@ type Props = NativeStackScreenProps< > export const CommunityGuidelinesScreen = (_props: Props) => { const pal = usePalette('default') + const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( @@ -26,16 +29,18 @@ export const CommunityGuidelinesScreen = (_props: Props) => { return ( <View> - <ViewHeader title="Community Guidelines" /> + <ViewHeader title={_(msg`Community Guidelines`)} /> <ScrollView style={[s.hContentRegion, pal.view]}> <View style={[s.p20]}> <Text style={pal.text}> - The Community Guidelines have been moved to{' '} - <TextLink - style={pal.link} - href="https://blueskyweb.xyz/support/community-guidelines" - text="blueskyweb.xyz/support/community-guidelines" - /> + <Trans> + The Community Guidelines have been moved to{' '} + <TextLink + style={pal.link} + href="https://blueskyweb.xyz/support/community-guidelines" + text="blueskyweb.xyz/support/community-guidelines" + /> + </Trans> </Text> </View> <View style={s.footerSpacer} /> diff --git a/src/view/screens/CopyrightPolicy.tsx b/src/view/screens/CopyrightPolicy.tsx index 816c1c1ee..2026f28c6 100644 --- a/src/view/screens/CopyrightPolicy.tsx +++ b/src/view/screens/CopyrightPolicy.tsx @@ -9,10 +9,13 @@ import {ScrollView} from 'view/com/util/Views' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'CopyrightPolicy'> export const CopyrightPolicyScreen = (_props: Props) => { const pal = usePalette('default') + const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( @@ -23,16 +26,18 @@ export const CopyrightPolicyScreen = (_props: Props) => { return ( <View> - <ViewHeader title="Copyright Policy" /> + <ViewHeader title={_(msg`Copyright Policy`)} /> <ScrollView style={[s.hContentRegion, pal.view]}> <View style={[s.p20]}> <Text style={pal.text}> - The Copyright Policy has been moved to{' '} - <TextLink - style={pal.link} - href="https://blueskyweb.xyz/support/community-guidelines" - text="blueskyweb.xyz/support/community-guidelines" - /> + <Trans> + The Copyright Policy has been moved to{' '} + <TextLink + style={pal.link} + href="https://blueskyweb.xyz/support/community-guidelines" + text="blueskyweb.xyz/support/community-guidelines" + /> + </Trans> </Text> </View> <View style={s.footerSpacer} /> diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 169660a8f..f319fbc39 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -1,15 +1,12 @@ import React from 'react' -import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native' +import {ActivityIndicator, StyleSheet, View, RefreshControl} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from 'view/com/util/ViewHeader' import {FAB} from 'view/com/util/fab/FAB' import {Link} from 'view/com/util/Link' import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types' -import {observer} from 'mobx-react-lite' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {ComposeIcon2, CogIcon} from 'lib/icons' import {s} from 'lib/styles' @@ -22,255 +19,525 @@ import { import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import debounce from 'lodash.debounce' import {Text} from 'view/com/util/text/Text' -import {MyFeedsItem} from 'state/models/ui/my-feeds' -import {FeedSourceModel} from 'state/models/content/feed-source' import {FlatList} from 'view/com/util/Views' import {useFocusEffect} from '@react-navigation/native' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useSetMinimalShellMode} from '#/state/shell' +import {usePreferencesQuery} from '#/state/queries/preferences' +import { + useFeedSourceInfoQuery, + useGetPopularFeedsQuery, + useSearchPopularFeedsMutation, +} from '#/state/queries/feed' +import {cleanError} from 'lib/strings/errors' +import {useComposerControls} from '#/state/shell/composer' +import {useSession} from '#/state/session' type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> -export const FeedsScreen = withAuthRequired( - observer<Props>(function FeedsScreenImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {isMobile, isTabletOrDesktop} = useWebMediaQueries() - const myFeeds = store.me.myFeeds - const [query, setQuery] = React.useState<string>('') - const debouncedSearchFeeds = React.useMemo( - () => debounce(q => myFeeds.discovery.search(q), 500), // debounce for 500ms - [myFeeds], - ) - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - myFeeds.setup() +type FlatlistSlice = + | { + type: 'error' + key: string + error: string + } + | { + type: 'savedFeedsHeader' + key: string + } + | { + type: 'savedFeedsLoading' + key: string + // pendingItems: number, + } + | { + type: 'savedFeedNoResults' + key: string + } + | { + type: 'savedFeed' + key: string + feedUri: string + } + | { + type: 'savedFeedsLoadMore' + key: string + } + | { + type: 'popularFeedsHeader' + key: string + } + | { + type: 'popularFeedsLoading' + key: string + } + | { + type: 'popularFeedsNoResults' + key: string + } + | { + type: 'popularFeed' + key: string + feedUri: string + } + | { + type: 'popularFeedsLoadingMore' + key: string + } - const softResetSub = store.onScreenSoftReset(() => myFeeds.refresh()) - return () => { - softResetSub.remove() - } - }, [store, myFeeds, setMinimalShellMode]), +export function FeedsScreen(_props: Props) { + const pal = usePalette('default') + const {openComposer} = useComposerControls() + const {isMobile, isTabletOrDesktop} = useWebMediaQueries() + const [query, setQuery] = React.useState('') + const [isPTR, setIsPTR] = React.useState(false) + const { + data: preferences, + isLoading: isPreferencesLoading, + error: preferencesError, + } = usePreferencesQuery() + const { + data: popularFeeds, + isFetching: isPopularFeedsFetching, + error: popularFeedsError, + refetch: refetchPopularFeeds, + fetchNextPage: fetchNextPopularFeedsPage, + isFetchingNextPage: isPopularFeedsFetchingNextPage, + hasNextPage: hasNextPopularFeedsPage, + } = useGetPopularFeedsQuery() + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const { + data: searchResults, + mutate: search, + reset: resetSearch, + isPending: isSearchPending, + error: searchError, + } = useSearchPopularFeedsMutation() + const {hasSession} = useSession() + + /** + * A search query is present. We may not have search results yet. + */ + const isUserSearching = query.length > 1 + const debouncedSearch = React.useMemo( + () => debounce(q => search(q), 500), // debounce for 500ms + [search], + ) + const onPressCompose = React.useCallback(() => { + openComposer({}) + }, [openComposer]) + const onChangeQuery = React.useCallback( + (text: string) => { + setQuery(text) + if (text.length > 1) { + debouncedSearch(text) + } else { + refetchPopularFeeds() + resetSearch() + } + }, + [setQuery, refetchPopularFeeds, debouncedSearch, resetSearch], + ) + const onPressCancelSearch = React.useCallback(() => { + setQuery('') + refetchPopularFeeds() + resetSearch() + }, [refetchPopularFeeds, setQuery, resetSearch]) + const onSubmitQuery = React.useCallback(() => { + debouncedSearch(query) + }, [query, debouncedSearch]) + const onPullToRefresh = React.useCallback(async () => { + setIsPTR(true) + await refetchPopularFeeds() + setIsPTR(false) + }, [setIsPTR, refetchPopularFeeds]) + const onEndReached = React.useCallback(() => { + if ( + isPopularFeedsFetching || + isUserSearching || + !hasNextPopularFeedsPage || + popularFeedsError ) - React.useEffect(() => { - // watch for changes to saved/pinned feeds - return myFeeds.registerListeners() - }, [myFeeds]) + return + fetchNextPopularFeedsPage() + }, [ + isPopularFeedsFetching, + isUserSearching, + popularFeedsError, + hasNextPopularFeedsPage, + fetchNextPopularFeedsPage, + ]) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + const items = React.useMemo(() => { + let slices: FlatlistSlice[] = [] - const onPressCompose = React.useCallback(() => { - store.shell.openComposer({}) - }, [store]) - const onChangeQuery = React.useCallback( - (text: string) => { - setQuery(text) - if (text.length > 1) { - debouncedSearchFeeds(text) + if (hasSession) { + slices.push({ + key: 'savedFeedsHeader', + type: 'savedFeedsHeader', + }) + + if (preferencesError) { + slices.push({ + key: 'savedFeedsError', + type: 'error', + error: cleanError(preferencesError.toString()), + }) + } else { + if (isPreferencesLoading || !preferences?.feeds?.saved) { + slices.push({ + key: 'savedFeedsLoading', + type: 'savedFeedsLoading', + // pendingItems: this.rootStore.preferences.savedFeeds.length || 3, + }) } else { - myFeeds.discovery.refresh() - } - }, - [debouncedSearchFeeds, myFeeds.discovery], - ) - const onPressCancelSearch = React.useCallback(() => { - setQuery('') - myFeeds.discovery.refresh() - }, [myFeeds]) - const onSubmitQuery = React.useCallback(() => { - debouncedSearchFeeds(query) - debouncedSearchFeeds.flush() - }, [debouncedSearchFeeds, query]) + if (preferences?.feeds?.saved.length === 0) { + slices.push({ + key: 'savedFeedNoResults', + type: 'savedFeedNoResults', + }) + } else { + const {saved, pinned} = preferences.feeds - const renderHeaderBtn = React.useCallback(() => { - return ( - <Link - href="/settings/saved-feeds" - hitSlop={10} - accessibilityRole="button" - accessibilityLabel="Edit Saved Feeds" - accessibilityHint="Opens screen to edit Saved Feeds"> - <CogIcon size={22} strokeWidth={2} style={pal.textLight} /> - </Link> - ) - }, [pal]) + slices = slices.concat( + pinned.map(uri => ({ + key: `savedFeed:${uri}`, + type: 'savedFeed', + feedUri: uri, + })), + ) - const onRefresh = React.useCallback(() => { - myFeeds.refresh() - }, [myFeeds]) + slices = slices.concat( + saved + .filter(uri => !pinned.includes(uri)) + .map(uri => ({ + key: `savedFeed:${uri}`, + type: 'savedFeed', + feedUri: uri, + })), + ) + } + } + } + } - const renderItem = React.useCallback( - ({item}: {item: MyFeedsItem}) => { - if (item.type === 'discover-feeds-loading') { - return <FeedFeedLoadingPlaceholder /> - } else if (item.type === 'spinner') { - return ( - <View style={s.p10}> - <ActivityIndicator /> - </View> - ) - } else if (item.type === 'error') { - return <ErrorMessage message={item.error} /> - } else if (item.type === 'saved-feeds-header') { - if (!isMobile) { - return ( - <View - style={[ - pal.view, - styles.header, - pal.border, - { - borderBottomWidth: 1, - }, - ]}> - <Text type="title-lg" style={[pal.text, s.bold]}> - My Feeds - </Text> - <Link - href="/settings/saved-feeds" - accessibilityLabel="Edit My Feeds" - accessibilityHint=""> - <CogIcon strokeWidth={1.5} style={pal.icon} size={28} /> - </Link> - </View> + slices.push({ + key: 'popularFeedsHeader', + type: 'popularFeedsHeader', + }) + + if (popularFeedsError || searchError) { + slices.push({ + key: 'popularFeedsError', + type: 'error', + error: cleanError( + popularFeedsError?.toString() ?? searchError?.toString() ?? '', + ), + }) + } else { + if (isUserSearching) { + if (isSearchPending || !searchResults) { + slices.push({ + key: 'popularFeedsLoading', + type: 'popularFeedsLoading', + }) + } else { + if (!searchResults || searchResults?.length === 0) { + slices.push({ + key: 'popularFeedsNoResults', + type: 'popularFeedsNoResults', + }) + } else { + slices = slices.concat( + searchResults.map(feed => ({ + key: `popularFeed:${feed.uri}`, + type: 'popularFeed', + feedUri: feed.uri, + })), ) } - return <View /> - } else if (item.type === 'saved-feeds-loading') { - return ( - <> - {Array.from(Array(item.numItems)).map((_, i) => ( - <SavedFeedLoadingPlaceholder key={`placeholder-${i}`} /> - ))} - </> - ) - } else if (item.type === 'saved-feed') { - return <SavedFeed feed={item.feed} /> - } else if (item.type === 'discover-feeds-header') { - return ( - <> - <View - style={[ - pal.view, - styles.header, - { - marginTop: 16, - paddingLeft: isMobile ? 12 : undefined, - paddingRight: 10, - paddingBottom: isMobile ? 6 : undefined, - }, - ]}> - <Text type="title-lg" style={[pal.text, s.bold]}> - Discover new feeds - </Text> - {!isMobile && ( - <SearchInput - query={query} - onChangeQuery={onChangeQuery} - onPressCancelSearch={onPressCancelSearch} - onSubmitQuery={onSubmitQuery} - style={{flex: 1, maxWidth: 250}} - /> - )} - </View> - {isMobile && ( - <View style={{paddingHorizontal: 8, paddingBottom: 10}}> - <SearchInput - query={query} - onChangeQuery={onChangeQuery} - onPressCancelSearch={onPressCancelSearch} - onSubmitQuery={onSubmitQuery} - /> - </View> - )} - </> - ) - } else if (item.type === 'discover-feed') { - return ( - <FeedSourceCard - item={item.feed} - showSaveBtn - showDescription - showLikes - /> - ) - } else if (item.type === 'discover-feeds-no-results') { + } + } else { + if (isPopularFeedsFetching && !popularFeeds?.pages) { + slices.push({ + key: 'popularFeedsLoading', + type: 'popularFeedsLoading', + }) + } else { + if ( + !popularFeeds?.pages || + popularFeeds?.pages[0]?.feeds?.length === 0 + ) { + slices.push({ + key: 'popularFeedsNoResults', + type: 'popularFeedsNoResults', + }) + } else { + for (const page of popularFeeds.pages || []) { + slices = slices.concat( + page.feeds + .filter(feed => !preferences?.feeds?.saved.includes(feed.uri)) + .map(feed => ({ + key: `popularFeed:${feed.uri}`, + type: 'popularFeed', + feedUri: feed.uri, + })), + ) + } + + if (isPopularFeedsFetchingNextPage) { + slices.push({ + key: 'popularFeedsLoadingMore', + type: 'popularFeedsLoadingMore', + }) + } + } + } + } + } + + return slices + }, [ + hasSession, + preferences, + isPreferencesLoading, + preferencesError, + popularFeeds, + isPopularFeedsFetching, + popularFeedsError, + isPopularFeedsFetchingNextPage, + searchResults, + isSearchPending, + searchError, + isUserSearching, + ]) + + const renderHeaderBtn = React.useCallback(() => { + return ( + <Link + href="/settings/saved-feeds" + hitSlop={10} + accessibilityRole="button" + accessibilityLabel={_(msg`Edit Saved Feeds`)} + accessibilityHint="Opens screen to edit Saved Feeds"> + <CogIcon size={22} strokeWidth={2} style={pal.textLight} /> + </Link> + ) + }, [pal, _]) + + const renderItem = React.useCallback( + ({item}: {item: FlatlistSlice}) => { + if (item.type === 'error') { + return <ErrorMessage message={item.error} /> + } else if ( + item.type === 'popularFeedsLoadingMore' || + item.type === 'savedFeedsLoading' + ) { + return ( + <View style={s.p10}> + <ActivityIndicator /> + </View> + ) + } else if (item.type === 'savedFeedsHeader') { + if (!isMobile) { return ( <View - style={{ - paddingHorizontal: 16, - paddingTop: 10, - paddingBottom: '150%', - }}> - <Text type="lg" style={pal.textLight}> - No results found for "{query}" + style={[ + pal.view, + styles.header, + pal.border, + { + borderBottomWidth: 1, + }, + ]}> + <Text type="title-lg" style={[pal.text, s.bold]}> + <Trans>My Feeds</Trans> </Text> + <Link + href="/settings/saved-feeds" + accessibilityLabel={_(msg`Edit My Feeds`)} + accessibilityHint=""> + <CogIcon strokeWidth={1.5} style={pal.icon} size={28} /> + </Link> </View> ) } - return null - }, - [isMobile, pal, query, onChangeQuery, onPressCancelSearch, onSubmitQuery], - ) + return <View /> + } else if (item.type === 'savedFeedNoResults') { + return ( + <View + style={{ + paddingHorizontal: 16, + paddingTop: 10, + }}> + <Text type="lg" style={pal.textLight}> + <Trans>You don't have any saved feeds!</Trans> + </Text> + </View> + ) + } else if (item.type === 'savedFeed') { + return <SavedFeed feedUri={item.feedUri} /> + } else if (item.type === 'popularFeedsHeader') { + return ( + <> + <View + style={[ + pal.view, + styles.header, + { + // This is first in the flatlist without a session -esb + marginTop: hasSession ? 16 : 0, + paddingLeft: isMobile ? 12 : undefined, + paddingRight: 10, + paddingBottom: isMobile ? 6 : undefined, + }, + ]}> + <Text type="title-lg" style={[pal.text, s.bold]}> + <Trans>Discover new feeds</Trans> + </Text> - return ( - <View style={[pal.view, styles.container]}> - {isMobile && ( - <ViewHeader - title="Feeds" - canGoBack={false} - renderButton={renderHeaderBtn} - showBorder + {!isMobile && ( + <SearchInput + query={query} + onChangeQuery={onChangeQuery} + onPressCancelSearch={onPressCancelSearch} + onSubmitQuery={onSubmitQuery} + style={{flex: 1, maxWidth: 250}} + /> + )} + </View> + + {isMobile && ( + <View style={{paddingHorizontal: 8, paddingBottom: 10}}> + <SearchInput + query={query} + onChangeQuery={onChangeQuery} + onPressCancelSearch={onPressCancelSearch} + onSubmitQuery={onSubmitQuery} + /> + </View> + )} + </> + ) + } else if (item.type === 'popularFeedsLoading') { + return <FeedFeedLoadingPlaceholder /> + } else if (item.type === 'popularFeed') { + return ( + <FeedSourceCard + feedUri={item.feedUri} + showSaveBtn={hasSession} + showDescription + showLikes + pinOnSave /> - )} + ) + } else if (item.type === 'popularFeedsNoResults') { + return ( + <View + style={{ + paddingHorizontal: 16, + paddingTop: 10, + paddingBottom: '150%', + }}> + <Text type="lg" style={pal.textLight}> + <Trans>No results found for "{query}"</Trans> + </Text> + </View> + ) + } + return null + }, + [ + _, + hasSession, + isMobile, + pal, + query, + onChangeQuery, + onPressCancelSearch, + onSubmitQuery, + ], + ) - <FlatList - style={[!isTabletOrDesktop && s.flex1, styles.list]} - data={myFeeds.items} - keyExtractor={item => item._reactKey} - contentContainerStyle={styles.contentContainer} - refreshControl={ - <RefreshControl - refreshing={myFeeds.isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - renderItem={renderItem} - initialNumToRender={10} - onEndReached={() => myFeeds.loadMore()} - extraData={myFeeds.isLoading} - // @ts-ignore our .web version only -prf - desktopFixedHeight + return ( + <View style={[pal.view, styles.container]}> + {isMobile && ( + <ViewHeader + title={_(msg`Feeds`)} + canGoBack={false} + renderButton={renderHeaderBtn} + showBorder /> + )} + + {preferences ? <View /> : <ActivityIndicator />} + + <FlatList + style={[!isTabletOrDesktop && s.flex1, styles.list]} + data={items} + keyExtractor={item => item.key} + contentContainerStyle={styles.contentContainer} + renderItem={renderItem} + refreshControl={ + <RefreshControl + refreshing={isPTR} + onRefresh={isUserSearching ? undefined : onPullToRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + initialNumToRender={10} + onEndReached={onEndReached} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + + {hasSession && ( <FAB testID="composeFAB" onPress={onPressCompose} icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} accessibilityRole="button" - accessibilityLabel="New post" + accessibilityLabel={_(msg`New post`)} accessibilityHint="" /> - </View> - ) - }), -) + )} + </View> + ) +} -function SavedFeed({feed}: {feed: FeedSourceModel}) { +function SavedFeed({feedUri}: {feedUri: string}) { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() + const {data: info, error} = useFeedSourceInfoQuery({uri: feedUri}) + + if (!info) + return ( + <SavedFeedLoadingPlaceholder + key={`savedFeedLoadingPlaceholder:${feedUri}`} + /> + ) + return ( <Link - testID={`saved-feed-${feed.displayName}`} - href={feed.href} + testID={`saved-feed-${info.displayName}`} + href={info.route.href} style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]} hoverStyle={pal.viewLight} - accessibilityLabel={feed.displayName} + accessibilityLabel={info.displayName} accessibilityHint="" asAnchor anchorNoUnderline> - {feed.error ? ( + {error ? ( <View style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}> <FontAwesomeIcon @@ -279,17 +546,17 @@ function SavedFeed({feed}: {feed: FeedSourceModel}) { /> </View> ) : ( - <UserAvatar type="algo" size={28} avatar={feed.avatar} /> + <UserAvatar type="algo" size={28} avatar={info.avatar} /> )} <View style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> <Text type="lg-medium" style={pal.text} numberOfLines={1}> - {feed.displayName} + {info.displayName} </Text> - {feed.error ? ( + {error ? ( <View style={[styles.offlineSlug, pal.borderDark]}> <Text type="xs" style={pal.textLight}> - Feed offline + <Trans>Feed offline</Trans> </Text> </View> ) : null} diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index c58175327..e8001e973 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,154 +1,178 @@ import React from 'react' -import {useWindowDimensions} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {observer} from 'mobx-react-lite' -import isEqual from 'lodash.isequal' +import {View, ActivityIndicator, StyleSheet} from 'react-native' +import {useFocusEffect, useIsFocused} from '@react-navigation/native' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' -import {PostsFeedModel} from 'state/models/feeds/posts' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' import {FeedsTabBar} from '../com/pager/FeedsTabBar' -import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' -import {useStores} from 'state/index' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager' import {FeedPage} from 'view/com/feeds/FeedPage' import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' - -export const POLL_FREQ = 30e3 // 30sec +import {usePreferencesQuery} from '#/state/queries/preferences' +import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' +import {emitSoftReset} from '#/state/events' +import {useSession} from '#/state/session' type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> -export const HomeScreen = withAuthRequired( - observer(function HomeScreenImpl({}: Props) { - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() - const pagerRef = React.useRef<PagerRef>(null) - const [selectedPage, setSelectedPage] = React.useState(0) - const [customFeeds, setCustomFeeds] = React.useState<PostsFeedModel[]>([]) - const [requestedCustomFeeds, setRequestedCustomFeeds] = React.useState< - string[] - >([]) +export function HomeScreen(props: Props) { + const {data: preferences} = usePreferencesQuery() + + if (preferences) { + return <HomeScreenReady {...props} preferences={preferences} /> + } else { + return ( + <View style={styles.loading}> + <ActivityIndicator size="large" /> + </View> + ) + } +} + +function HomeScreenReady({ + preferences, +}: Props & { + preferences: UsePreferencesQueryResponse +}) { + const {hasSession} = useSession() + const setMinimalShellMode = useSetMinimalShellMode() + const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + const [selectedPage, setSelectedPage] = React.useState(0) + const isPageFocused = useIsFocused() - React.useEffect(() => { - const pinned = store.preferences.pinnedFeeds + /** + * Used to ensure that we re-compute `customFeeds` AND force a re-render of + * the pager with the new order of feeds. + */ + const pinnedFeedOrderKey = JSON.stringify(preferences.feeds.pinned) - if (isEqual(pinned, requestedCustomFeeds)) { - // no changes - return + const customFeeds = React.useMemo(() => { + const pinned = preferences.feeds.pinned + const feeds: FeedDescriptor[] = [] + for (const uri of pinned) { + if (uri.includes('app.bsky.feed.generator')) { + feeds.push(`feedgen|${uri}`) + } else if (uri.includes('app.bsky.graph.list')) { + feeds.push(`list|${uri}`) } + } + return feeds + }, [preferences.feeds.pinned]) - const feeds = [] - for (const uri of pinned) { - if (uri.includes('app.bsky.feed.generator')) { - const model = new PostsFeedModel(store, 'custom', {feed: uri}) - feeds.push(model) - } else if (uri.includes('app.bsky.graph.list')) { - const model = new PostsFeedModel(store, 'list', {list: uri}) - feeds.push(model) - } + const homeFeedParams = React.useMemo<FeedParams>(() => { + return { + mergeFeedEnabled: Boolean(preferences.feedViewPrefs.lab_mergeFeedEnabled), + mergeFeedSources: preferences.feeds.saved, + } + }, [preferences]) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + setDrawerSwipeDisabled(selectedPage > 0) + return () => { + setDrawerSwipeDisabled(false) } - pagerRef.current?.setPage(0) - setCustomFeeds(feeds) - setRequestedCustomFeeds(pinned) - }, [ - store, - store.preferences.pinnedFeeds, - customFeeds, - setCustomFeeds, - pagerRef, - requestedCustomFeeds, - setRequestedCustomFeeds, - ]) + }, [setDrawerSwipeDisabled, selectedPage, setMinimalShellMode]), + ) - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - setDrawerSwipeDisabled(selectedPage > 0) - return () => { - setDrawerSwipeDisabled(false) - } - }, [setDrawerSwipeDisabled, selectedPage, setMinimalShellMode]), - ) + const onPageSelected = React.useCallback( + (index: number) => { + setMinimalShellMode(false) + setSelectedPage(index) + setDrawerSwipeDisabled(index > 0) + }, + [setDrawerSwipeDisabled, setSelectedPage, setMinimalShellMode], + ) + + const onPressSelected = React.useCallback(() => { + emitSoftReset() + }, []) - const onPageSelected = React.useCallback( - (index: number) => { + const onPageScrollStateChanged = React.useCallback( + (state: 'idle' | 'dragging' | 'settling') => { + if (state === 'dragging') { setMinimalShellMode(false) - setSelectedPage(index) - setDrawerSwipeDisabled(index > 0) - }, - [setDrawerSwipeDisabled, setSelectedPage, setMinimalShellMode], - ) + } + }, + [setMinimalShellMode], + ) - const onPressSelected = React.useCallback(() => { - store.emitScreenSoftReset() - }, [store]) + const renderTabBar = React.useCallback( + (props: RenderTabBarFnProps) => { + return ( + <FeedsTabBar + key="FEEDS_TAB_BAR" + selectedPage={props.selectedPage} + onSelect={props.onSelect} + testID="homeScreenFeedTabs" + onPressSelected={onPressSelected} + /> + ) + }, + [onPressSelected], + ) - const renderTabBar = React.useCallback( - (props: RenderTabBarFnProps) => { + const renderFollowingEmptyState = React.useCallback(() => { + return <FollowingEmptyState /> + }, []) + + const renderCustomFeedEmptyState = React.useCallback(() => { + return <CustomFeedEmptyState /> + }, []) + + return hasSession ? ( + <Pager + key={pinnedFeedOrderKey} + testID="homeScreen" + onPageSelected={onPageSelected} + onPageScrollStateChanged={onPageScrollStateChanged} + renderTabBar={renderTabBar} + tabBarPosition="top"> + <FeedPage + key="1" + testID="followingFeedPage" + isPageFocused={selectedPage === 0 && isPageFocused} + feed={homeFeedParams.mergeFeedEnabled ? 'home' : 'following'} + feedParams={homeFeedParams} + renderEmptyState={renderFollowingEmptyState} + renderEndOfFeed={FollowingEndOfFeed} + /> + {customFeeds.map((f, index) => { return ( - <FeedsTabBar - key="FEEDS_TAB_BAR" - selectedPage={props.selectedPage} - onSelect={props.onSelect} - testID="homeScreenFeedTabs" - onPressSelected={onPressSelected} + <FeedPage + key={f} + testID="customFeedPage" + isPageFocused={selectedPage === 1 + index && isPageFocused} + feed={f} + renderEmptyState={renderCustomFeedEmptyState} /> ) - }, - [onPressSelected], - ) - - const renderFollowingEmptyState = React.useCallback(() => { - return <FollowingEmptyState /> - }, []) - - const renderCustomFeedEmptyState = React.useCallback(() => { - return <CustomFeedEmptyState /> - }, []) - - return ( - <Pager - ref={pagerRef} - testID="homeScreen" - onPageSelected={onPageSelected} - renderTabBar={renderTabBar} - tabBarPosition="top"> - <FeedPage - key="1" - testID="followingFeedPage" - isPageFocused={selectedPage === 0} - feed={store.me.mainFeed} - renderEmptyState={renderFollowingEmptyState} - renderEndOfFeed={FollowingEndOfFeed} - /> - {customFeeds.map((f, index) => { - return ( - <FeedPage - key={f.reactKey} - testID="customFeedPage" - isPageFocused={selectedPage === 1 + index} - feed={f} - renderEmptyState={renderCustomFeedEmptyState} - /> - ) - })} - </Pager> - ) - }), -) - -export function useHeaderOffset() { - const {isDesktop, isTablet} = useWebMediaQueries() - const {fontScale} = useWindowDimensions() - if (isDesktop) { - return 0 - } - if (isTablet) { - return 50 - } - // default text takes 44px, plus 34px of pad - // scale the 44px by the font scale - return 34 + 44 * fontScale + })} + </Pager> + ) : ( + <Pager + testID="homeScreen" + onPageSelected={onPageSelected} + onPageScrollStateChanged={onPageScrollStateChanged} + renderTabBar={renderTabBar} + tabBarPosition="top"> + <FeedPage + testID="customFeedPage" + isPageFocused={isPageFocused} + feed={`feedgen|at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot`} + renderEmptyState={renderCustomFeedEmptyState} + /> + </Pager> + ) } + +const styles = StyleSheet.create({ + loading: { + height: '100%', + alignContent: 'center', + justifyContent: 'center', + paddingBottom: 100, + }, +}) diff --git a/src/view/screens/LanguageSettings.tsx b/src/view/screens/LanguageSettings.tsx index a68a3b5e3..7a2e54dc8 100644 --- a/src/view/screens/LanguageSettings.tsx +++ b/src/view/screens/LanguageSettings.tsx @@ -1,8 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {Text} from '../com/util/text/Text' -import {useStores} from 'state/index' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' @@ -16,20 +14,25 @@ import { } from '@fortawesome/react-native-fontawesome' import {useAnalytics} from 'lib/analytics/analytics' import {useFocusEffect} from '@react-navigation/native' -import {LANGUAGES} from 'lib/../locale/languages' +import {APP_LANGUAGES, LANGUAGES} from 'lib/../locale/languages' import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'LanguageSettings'> -export const LanguageSettingsScreen = observer(function LanguageSettingsImpl( - _: Props, -) { +export function LanguageSettingsScreen(_props: Props) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const langPrefs = useLanguagePrefs() + const setLangPrefs = useLanguagePrefsApi() const {isTabletOrDesktop} = useWebMediaQueries() const {screen, track} = useAnalytics() const setMinimalShellMode = useSetMinimalShellMode() + const {openModal} = useModalControls() useFocusEffect( React.useCallback(() => { @@ -40,26 +43,37 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl( const onPressContentLanguages = React.useCallback(() => { track('Settings:ContentlanguagesButtonClicked') - store.shell.openModal({name: 'content-languages-settings'}) - }, [track, store]) + openModal({name: 'content-languages-settings'}) + }, [track, openModal]) const onChangePrimaryLanguage = React.useCallback( (value: Parameters<PickerSelectProps['onValueChange']>[0]) => { - store.preferences.setPrimaryLanguage(value) + if (langPrefs.primaryLanguage !== value) { + setLangPrefs.setPrimaryLanguage(value) + } }, - [store.preferences], + [langPrefs, setLangPrefs], + ) + + const onChangeAppLanguage = React.useCallback( + (value: Parameters<PickerSelectProps['onValueChange']>[0]) => { + if (langPrefs.appLanguage !== value) { + setLangPrefs.setAppLanguage(value) + } + }, + [langPrefs, setLangPrefs], ) const myLanguages = React.useMemo(() => { return ( - store.preferences.contentLanguages + langPrefs.contentLanguages .map(lang => LANGUAGES.find(l => l.code2 === lang)) .filter(Boolean) // @ts-ignore .map(l => l.name) .join(', ') ) - }, [store.preferences.contentLanguages]) + }, [langPrefs.contentLanguages]) return ( <CenteredView @@ -69,20 +83,114 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl( styles.container, isTabletOrDesktop && styles.desktopContainer, ]}> - <ViewHeader title="Language Settings" showOnDesktop /> + <ViewHeader title={_(msg`Language Settings`)} showOnDesktop /> <View style={{paddingTop: 20, paddingHorizontal: 20}}> + {/* APP LANGUAGE */} + <View style={{paddingBottom: 20}}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + <Trans>App Language</Trans> + </Text> + <Text style={[pal.text, s.pb10]}> + <Trans> + Select your app language for the default text to display in the + app + </Trans> + </Text> + + <View style={{position: 'relative'}}> + <RNPickerSelect + value={langPrefs.appLanguage} + onValueChange={onChangeAppLanguage} + items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({ + label: l.name, + value: l.code2, + key: l.code2, + }))} + style={{ + inputAndroid: { + backgroundColor: pal.viewLight.backgroundColor, + color: pal.text.color, + fontSize: 14, + letterSpacing: 0.5, + fontWeight: '500', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 24, + }, + inputIOS: { + backgroundColor: pal.viewLight.backgroundColor, + color: pal.text.color, + fontSize: 14, + letterSpacing: 0.5, + fontWeight: '500', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 24, + }, + inputWeb: { + // @ts-ignore web only + cursor: 'pointer', + '-moz-appearance': 'none', + '-webkit-appearance': 'none', + appearance: 'none', + outline: 0, + borderWidth: 0, + backgroundColor: pal.viewLight.backgroundColor, + color: pal.text.color, + fontSize: 14, + letterSpacing: 0.5, + fontWeight: '500', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 24, + }, + }} + /> + + <View + style={{ + position: 'absolute', + top: 1, + right: 1, + bottom: 1, + width: 40, + backgroundColor: pal.viewLight.backgroundColor, + borderRadius: 24, + pointerEvents: 'none', + alignItems: 'center', + justifyContent: 'center', + }}> + <FontAwesomeIcon + icon="chevron-down" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + </View> + </View> + + <View + style={{ + height: 1, + backgroundColor: pal.border.borderColor, + marginBottom: 20, + }} + /> + + {/* PRIMARY LANGUAGE */} <View style={{paddingBottom: 20}}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Primary Language + <Trans>Primary Language</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Select your preferred language for translations in your feed. + <Trans> + Select your preferred language for translations in your feed. + </Trans> </Text> <View style={{position: 'relative'}}> <RNPickerSelect - value={store.preferences.primaryLanguage} + value={langPrefs.primaryLanguage} onValueChange={onChangePrimaryLanguage} items={LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({ label: l.name, @@ -159,13 +267,16 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl( }} /> + {/* CONTENT LANGUAGES */} <View style={{paddingBottom: 20}}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Content Languages + <Trans>Content Languages</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Select which languages you want your subscribed feeds to include. If - none are selected, all languages will be shown. + <Trans> + Select which languages you want your subscribed feeds to include. + If none are selected, all languages will be shown. + </Trans> </Text> <Button @@ -187,7 +298,7 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl( </View> </CenteredView> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx index a64b0ca3b..d28db7c6c 100644 --- a/src/view/screens/Lists.tsx +++ b/src/view/screens/Lists.tsx @@ -3,12 +3,8 @@ import {View} from 'react-native' import {useFocusEffect, useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {AtUri} from '@atproto/api' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {useStores} from 'state/index' -import {ListsListModel} from 'state/models/lists/lists-list' -import {ListsList} from 'view/com/lists/ListsList' +import {MyLists} from '#/view/com/lists/MyLists' import {Text} from 'view/com/util/text/Text' import {Button} from 'view/com/util/forms/Button' import {NavigationProp} from 'lib/routes/types' @@ -17,78 +13,72 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {Trans} from '@lingui/macro' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'> -export const ListsScreen = withAuthRequired( - observer(function ListsScreenImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {isMobile} = useWebMediaQueries() - const navigation = useNavigation<NavigationProp>() +export function ListsScreen({}: Props) { + const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() + const {isMobile} = useWebMediaQueries() + const navigation = useNavigation<NavigationProp>() + const {openModal} = useModalControls() - const listsLists: ListsListModel = React.useMemo( - () => new ListsListModel(store, 'my-curatelists'), - [store], - ) + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - listsLists.refresh() - }, [listsLists, setMinimalShellMode]), - ) + const onPressNewList = React.useCallback(() => { + openModal({ + name: 'create-or-edit-list', + purpose: 'app.bsky.graph.defs#curatelist', + onSave: (uri: string) => { + try { + const urip = new AtUri(uri) + navigation.navigate('ProfileList', { + name: urip.hostname, + rkey: urip.rkey, + }) + } catch {} + }, + }) + }, [openModal, navigation]) - const onPressNewList = React.useCallback(() => { - store.shell.openModal({ - name: 'create-or-edit-list', - purpose: 'app.bsky.graph.defs#curatelist', - onSave: (uri: string) => { - try { - const urip = new AtUri(uri) - navigation.navigate('ProfileList', { - name: urip.hostname, - rkey: urip.rkey, - }) - } catch {} - }, - }) - }, [store, navigation]) - - return ( - <View style={s.hContentRegion} testID="listsScreen"> - <SimpleViewHeader - showBackButton={isMobile} - style={ - !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] - }> - <View style={{flex: 1}}> - <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> - User Lists - </Text> - <Text style={pal.textLight}> - Public, shareable lists which can drive feeds. + return ( + <View style={s.hContentRegion} testID="listsScreen"> + <SimpleViewHeader + showBackButton={isMobile} + style={ + !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] + }> + <View style={{flex: 1}}> + <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> + <Trans>User Lists</Trans> + </Text> + <Text style={pal.textLight}> + <Trans>Public, shareable lists which can drive feeds.</Trans> + </Text> + </View> + <View> + <Button + testID="newUserListBtn" + type="default" + onPress={onPressNewList} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }}> + <FontAwesomeIcon icon="plus" color={pal.colors.text} /> + <Text type="button" style={pal.text}> + <Trans>New</Trans> </Text> - </View> - <View> - <Button - testID="newUserListBtn" - type="default" - onPress={onPressNewList} - style={{ - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }}> - <FontAwesomeIcon icon="plus" color={pal.colors.text} /> - <Text type="button" style={pal.text}> - New - </Text> - </Button> - </View> - </SimpleViewHeader> - <ListsList listsList={listsLists} style={s.flexGrow1} /> - </View> - ) - }), -) + </Button> + </View> + </SimpleViewHeader> + <MyLists filter="curate" style={s.flexGrow1} /> + </View> + ) +} diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx index f524279a5..8680b851b 100644 --- a/src/view/screens/Log.tsx +++ b/src/view/screens/Log.tsx @@ -1,7 +1,6 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {ScrollView} from '../com/util/Views' @@ -11,13 +10,16 @@ import {Text} from '../com/util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {getEntries} from '#/logger/logDump' import {ago} from 'lib/strings/time' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' import {useSetMinimalShellMode} from '#/state/shell' -export const LogScreen = observer(function Log({}: NativeStackScreenProps< +export function LogScreen({}: NativeStackScreenProps< CommonNavigatorParams, 'Log' >) { const pal = usePalette('default') + const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() const [expanded, setExpanded] = React.useState<string[]>([]) @@ -47,7 +49,7 @@ export const LogScreen = observer(function Log({}: NativeStackScreenProps< <TouchableOpacity style={[styles.entry, pal.border, pal.view]} onPress={toggler(entry.id)} - accessibilityLabel="View debug entry" + accessibilityLabel={_(msg`View debug entry`)} accessibilityHint="Opens additional details for a debug entry"> {entry.level === 'debug' ? ( <FontAwesomeIcon icon="info" /> @@ -85,7 +87,7 @@ export const LogScreen = observer(function Log({}: NativeStackScreenProps< </ScrollView> </View> ) -}) +} const styles = StyleSheet.create({ entry: { diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx index 142f3bce8..4d8d8cad7 100644 --- a/src/view/screens/Moderation.tsx +++ b/src/view/screens/Moderation.tsx @@ -5,10 +5,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {useStores} from 'state/index' import {s} from 'lib/styles' import {CenteredView} from '../com/util/Views' import {ViewHeader} from '../com/util/ViewHeader' @@ -18,101 +15,103 @@ import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'> -export const ModerationScreen = withAuthRequired( - observer(function Moderation({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {screen, track} = useAnalytics() - const {isTabletOrDesktop} = useWebMediaQueries() +export function ModerationScreen({}: Props) { + const pal = usePalette('default') + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const {screen, track} = useAnalytics() + const {isTabletOrDesktop} = useWebMediaQueries() + const {openModal} = useModalControls() - useFocusEffect( - React.useCallback(() => { - screen('Moderation') - setMinimalShellMode(false) - }, [screen, setMinimalShellMode]), - ) + useFocusEffect( + React.useCallback(() => { + screen('Moderation') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) - const onPressContentFiltering = React.useCallback(() => { - track('Moderation:ContentfilteringButtonClicked') - store.shell.openModal({name: 'content-filtering-settings'}) - }, [track, store]) + const onPressContentFiltering = React.useCallback(() => { + track('Moderation:ContentfilteringButtonClicked') + openModal({name: 'content-filtering-settings'}) + }, [track, openModal]) - return ( - <CenteredView - style={[ - s.hContentRegion, - pal.border, - isTabletOrDesktop ? styles.desktopContainer : pal.viewLight, - ]} - testID="moderationScreen"> - <ViewHeader title="Moderation" showOnDesktop /> - <View style={styles.spacer} /> - <TouchableOpacity - testID="contentFilteringBtn" - style={[styles.linkCard, pal.view]} - onPress={onPressContentFiltering} - accessibilityRole="tab" - accessibilityHint="Content filtering" - accessibilityLabel=""> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="eye" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Content filtering - </Text> - </TouchableOpacity> - <Link - testID="moderationlistsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/modlists"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="users-slash" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Moderation lists - </Text> - </Link> - <Link - testID="mutedAccountsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/muted-accounts"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="user-slash" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Muted accounts - </Text> - </Link> - <Link - testID="blockedAccountsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/blocked-accounts"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="ban" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Blocked accounts - </Text> - </Link> - </CenteredView> - ) - }), -) + return ( + <CenteredView + style={[ + s.hContentRegion, + pal.border, + isTabletOrDesktop ? styles.desktopContainer : pal.viewLight, + ]} + testID="moderationScreen"> + <ViewHeader title={_(msg`Moderation`)} showOnDesktop /> + <View style={styles.spacer} /> + <TouchableOpacity + testID="contentFilteringBtn" + style={[styles.linkCard, pal.view]} + onPress={onPressContentFiltering} + accessibilityRole="tab" + accessibilityHint="Content filtering" + accessibilityLabel=""> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="eye" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Content filtering</Trans> + </Text> + </TouchableOpacity> + <Link + testID="moderationlistsBtn" + style={[styles.linkCard, pal.view]} + href="/moderation/modlists"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="users-slash" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Moderation lists</Trans> + </Text> + </Link> + <Link + testID="mutedAccountsBtn" + style={[styles.linkCard, pal.view]} + href="/moderation/muted-accounts"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="user-slash" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Muted accounts</Trans> + </Text> + </Link> + <Link + testID="blockedAccountsBtn" + style={[styles.linkCard, pal.view]} + href="/moderation/blocked-accounts"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="ban" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Blocked accounts</Trans> + </Text> + </Link> + </CenteredView> + ) +} const styles = StyleSheet.create({ desktopContainer: { diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx index 0dc3b706b..8f6e2f729 100644 --- a/src/view/screens/ModerationBlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react' +import React from 'react' import { ActivityIndicator, FlatList, @@ -8,133 +8,165 @@ import { } from 'react-native' import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {Text} from '../com/util/text/Text' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {CommonNavigatorParams} from 'lib/routes/types' -import {BlockedAccountsModel} from 'state/models/lists/blocked-accounts' import {useAnalytics} from 'lib/analytics/analytics' import {useFocusEffect} from '@react-navigation/native' import {ViewHeader} from '../com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' +import {ErrorScreen} from '../com/util/error/ErrorScreen' import {ProfileCard} from 'view/com/profile/ProfileCard' import {logger} from '#/logger' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMyBlockedAccountsQuery} from '#/state/queries/my-blocked-accounts' +import {cleanError} from '#/lib/strings/errors' type Props = NativeStackScreenProps< CommonNavigatorParams, 'ModerationBlockedAccounts' > -export const ModerationBlockedAccounts = withAuthRequired( - observer(function ModerationBlockedAccountsImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {isTabletOrDesktop} = useWebMediaQueries() - const {screen} = useAnalytics() - const blockedAccounts = useMemo( - () => new BlockedAccountsModel(store), - [store], - ) +export function ModerationBlockedAccounts({}: Props) { + const pal = usePalette('default') + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const {isTabletOrDesktop} = useWebMediaQueries() + const {screen} = useAnalytics() + const [isPTRing, setIsPTRing] = React.useState(false) + const { + data, + isFetching, + isError, + error, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useMyBlockedAccountsQuery() + const isEmpty = !isFetching && !data?.pages[0]?.blocks.length + const profiles = React.useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.blocks) + } + return [] + }, [data]) - useFocusEffect( - React.useCallback(() => { - screen('BlockedAccounts') - setMinimalShellMode(false) - blockedAccounts.refresh() - }, [screen, setMinimalShellMode, blockedAccounts]), - ) + useFocusEffect( + React.useCallback(() => { + screen('BlockedAccounts') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) - const onRefresh = React.useCallback(() => { - blockedAccounts.refresh() - }, [blockedAccounts]) - const onEndReached = React.useCallback(() => { - blockedAccounts - .loadMore() - .catch(err => - logger.error('Failed to load more blocked accounts', {error: err}), - ) - }, [blockedAccounts]) + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh my muted accounts', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) - const renderItem = ({ - item, - index, - }: { - item: ActorDefs.ProfileView - index: number - }) => ( - <ProfileCard - testID={`blockedAccount-${index}`} - key={item.did} - profile={item} - /> - ) - return ( - <CenteredView + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more of my muted accounts', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const renderItem = ({ + item, + index, + }: { + item: ActorDefs.ProfileView + index: number + }) => ( + <ProfileCard + testID={`blockedAccount-${index}`} + key={item.did} + profile={item} + /> + ) + return ( + <CenteredView + style={[ + styles.container, + isTabletOrDesktop && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="blockedAccountsScreen"> + <ViewHeader title={_(msg`Blocked Accounts`)} showOnDesktop /> + <Text + type="sm" style={[ - styles.container, - isTabletOrDesktop && styles.containerDesktop, - pal.view, - pal.border, - ]} - testID="blockedAccountsScreen"> - <ViewHeader title="Blocked Accounts" showOnDesktop /> - <Text - type="sm" - style={[ - styles.description, - pal.text, - isTabletOrDesktop && styles.descriptionDesktop, - ]}> + styles.description, + pal.text, + isTabletOrDesktop && styles.descriptionDesktop, + ]}> + <Trans> Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. You will not see their content and they will be prevented from seeing yours. - </Text> - {!blockedAccounts.hasContent ? ( - <View style={[pal.border, !isTabletOrDesktop && styles.flex1]}> + </Trans> + </Text> + {isEmpty ? ( + <View style={[pal.border, !isTabletOrDesktop && styles.flex1]}> + {isError ? ( + <ErrorScreen + title="Oops!" + message={cleanError(error)} + onPressTryAgain={refetch} + /> + ) : ( <View style={[styles.empty, pal.viewLight]}> <Text type="lg" style={[pal.text, styles.emptyText]}> - You have not blocked any accounts yet. To block an account, go - to their profile and selected "Block account" from the menu on - their account. + <Trans> + You have not blocked any accounts yet. To block an account, go + to their profile and selected "Block account" from the menu on + their account. + </Trans> </Text> </View> - </View> - ) : ( - <FlatList - style={[!isTabletOrDesktop && styles.flex1]} - data={blockedAccounts.blocks} - keyExtractor={(item: ActorDefs.ProfileView) => item.did} - refreshControl={ - <RefreshControl - refreshing={blockedAccounts.isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - onEndReached={onEndReached} - renderItem={renderItem} - initialNumToRender={15} - // FIXME(dan) - // eslint-disable-next-line react/no-unstable-nested-components - ListFooterComponent={() => ( - <View style={styles.footer}> - {blockedAccounts.isLoading && <ActivityIndicator />} - </View> - )} - extraData={blockedAccounts.isLoading} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> - )} - </CenteredView> - ) - }), -) + )} + </View> + ) : ( + <FlatList + style={[!isTabletOrDesktop && styles.flex1]} + data={profiles} + keyExtractor={(item: ActorDefs.ProfileView) => item.did} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onEndReached={onEndReached} + renderItem={renderItem} + initialNumToRender={15} + // FIXME(dan) + + ListFooterComponent={() => ( + <View style={styles.footer}> + {(isFetching || isFetchingNextPage) && <ActivityIndicator />} + </View> + )} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + )} + </CenteredView> + ) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx index 8794c6d17..145b35a42 100644 --- a/src/view/screens/ModerationModlists.tsx +++ b/src/view/screens/ModerationModlists.tsx @@ -3,12 +3,8 @@ import {View} from 'react-native' import {useFocusEffect, useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {AtUri} from '@atproto/api' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {useStores} from 'state/index' -import {ListsListModel} from 'state/models/lists/lists-list' -import {ListsList} from 'view/com/lists/ListsList' +import {MyLists} from '#/view/com/lists/MyLists' import {Text} from 'view/com/util/text/Text' import {Button} from 'view/com/util/forms/Button' import {NavigationProp} from 'lib/routes/types' @@ -17,78 +13,71 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ModerationModlists'> -export const ModerationModlistsScreen = withAuthRequired( - observer(function ModerationModlistsScreenImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {isMobile} = useWebMediaQueries() - const navigation = useNavigation<NavigationProp>() +export function ModerationModlistsScreen({}: Props) { + const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() + const {isMobile} = useWebMediaQueries() + const navigation = useNavigation<NavigationProp>() + const {openModal} = useModalControls() - const mutelists: ListsListModel = React.useMemo( - () => new ListsListModel(store, 'my-modlists'), - [store], - ) + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - mutelists.refresh() - }, [mutelists, setMinimalShellMode]), - ) + const onPressNewList = React.useCallback(() => { + openModal({ + name: 'create-or-edit-list', + purpose: 'app.bsky.graph.defs#modlist', + onSave: (uri: string) => { + try { + const urip = new AtUri(uri) + navigation.navigate('ProfileList', { + name: urip.hostname, + rkey: urip.rkey, + }) + } catch {} + }, + }) + }, [openModal, navigation]) - const onPressNewList = React.useCallback(() => { - store.shell.openModal({ - name: 'create-or-edit-list', - purpose: 'app.bsky.graph.defs#modlist', - onSave: (uri: string) => { - try { - const urip = new AtUri(uri) - navigation.navigate('ProfileList', { - name: urip.hostname, - rkey: urip.rkey, - }) - } catch {} - }, - }) - }, [store, navigation]) - - return ( - <View style={s.hContentRegion} testID="moderationModlistsScreen"> - <SimpleViewHeader - showBackButton={isMobile} - style={ - !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] - }> - <View style={{flex: 1}}> - <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> - Moderation Lists - </Text> - <Text style={pal.textLight}> - Public, shareable lists of users to mute or block in bulk. + return ( + <View style={s.hContentRegion} testID="moderationModlistsScreen"> + <SimpleViewHeader + showBackButton={isMobile} + style={ + !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] + }> + <View style={{flex: 1}}> + <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> + Moderation Lists + </Text> + <Text style={pal.textLight}> + Public, shareable lists of users to mute or block in bulk. + </Text> + </View> + <View> + <Button + testID="newModListBtn" + type="default" + onPress={onPressNewList} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }}> + <FontAwesomeIcon icon="plus" color={pal.colors.text} /> + <Text type="button" style={pal.text}> + New </Text> - </View> - <View> - <Button - testID="newModListBtn" - type="default" - onPress={onPressNewList} - style={{ - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }}> - <FontAwesomeIcon icon="plus" color={pal.colors.text} /> - <Text type="button" style={pal.text}> - New - </Text> - </Button> - </View> - </SimpleViewHeader> - <ListsList listsList={mutelists} style={s.flexGrow1} /> - </View> - ) - }), -) + </Button> + </View> + </SimpleViewHeader> + <MyLists filter="mod" style={s.flexGrow1} /> + </View> + ) +} diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx index 2fa27ee54..41aee9f2f 100644 --- a/src/view/screens/ModerationMutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react' +import React from 'react' import { ActivityIndicator, FlatList, @@ -8,129 +8,164 @@ import { } from 'react-native' import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {Text} from '../com/util/text/Text' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {CommonNavigatorParams} from 'lib/routes/types' -import {MutedAccountsModel} from 'state/models/lists/muted-accounts' import {useAnalytics} from 'lib/analytics/analytics' import {useFocusEffect} from '@react-navigation/native' import {ViewHeader} from '../com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' +import {ErrorScreen} from '../com/util/error/ErrorScreen' import {ProfileCard} from 'view/com/profile/ProfileCard' import {logger} from '#/logger' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMyMutedAccountsQuery} from '#/state/queries/my-muted-accounts' +import {cleanError} from '#/lib/strings/errors' type Props = NativeStackScreenProps< CommonNavigatorParams, 'ModerationMutedAccounts' > -export const ModerationMutedAccounts = withAuthRequired( - observer(function ModerationMutedAccountsImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {isTabletOrDesktop} = useWebMediaQueries() - const {screen} = useAnalytics() - const mutedAccounts = useMemo(() => new MutedAccountsModel(store), [store]) +export function ModerationMutedAccounts({}: Props) { + const pal = usePalette('default') + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const {isTabletOrDesktop} = useWebMediaQueries() + const {screen} = useAnalytics() + const [isPTRing, setIsPTRing] = React.useState(false) + const { + data, + isFetching, + isError, + error, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useMyMutedAccountsQuery() + const isEmpty = !isFetching && !data?.pages[0]?.mutes.length + const profiles = React.useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.mutes) + } + return [] + }, [data]) - useFocusEffect( - React.useCallback(() => { - screen('MutedAccounts') - setMinimalShellMode(false) - mutedAccounts.refresh() - }, [screen, setMinimalShellMode, mutedAccounts]), - ) + useFocusEffect( + React.useCallback(() => { + screen('MutedAccounts') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) - const onRefresh = React.useCallback(() => { - mutedAccounts.refresh() - }, [mutedAccounts]) - const onEndReached = React.useCallback(() => { - mutedAccounts - .loadMore() - .catch(err => - logger.error('Failed to load more muted accounts', {error: err}), - ) - }, [mutedAccounts]) + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh my muted accounts', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) - const renderItem = ({ - item, - index, - }: { - item: ActorDefs.ProfileView - index: number - }) => ( - <ProfileCard - testID={`mutedAccount-${index}`} - key={item.did} - profile={item} - /> - ) - return ( - <CenteredView + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more of my muted accounts', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const renderItem = ({ + item, + index, + }: { + item: ActorDefs.ProfileView + index: number + }) => ( + <ProfileCard + testID={`mutedAccount-${index}`} + key={item.did} + profile={item} + /> + ) + return ( + <CenteredView + style={[ + styles.container, + isTabletOrDesktop && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="mutedAccountsScreen"> + <ViewHeader title={_(msg`Muted Accounts`)} showOnDesktop /> + <Text + type="sm" style={[ - styles.container, - isTabletOrDesktop && styles.containerDesktop, - pal.view, - pal.border, - ]} - testID="mutedAccountsScreen"> - <ViewHeader title="Muted Accounts" showOnDesktop /> - <Text - type="sm" - style={[ - styles.description, - pal.text, - isTabletOrDesktop && styles.descriptionDesktop, - ]}> + styles.description, + pal.text, + isTabletOrDesktop && styles.descriptionDesktop, + ]}> + <Trans> Muted accounts have their posts removed from your feed and from your notifications. Mutes are completely private. - </Text> - {!mutedAccounts.hasContent ? ( - <View style={[pal.border, !isTabletOrDesktop && styles.flex1]}> + </Trans> + </Text> + {isEmpty ? ( + <View style={[pal.border, !isTabletOrDesktop && styles.flex1]}> + {isError ? ( + <ErrorScreen + title="Oops!" + message={cleanError(error)} + onPressTryAgain={refetch} + /> + ) : ( <View style={[styles.empty, pal.viewLight]}> <Text type="lg" style={[pal.text, styles.emptyText]}> - You have not muted any accounts yet. To mute an account, go to - their profile and selected "Mute account" from the menu on their - account. + <Trans> + You have not muted any accounts yet. To mute an account, go to + their profile and selected "Mute account" from the menu on + their account. + </Trans> </Text> </View> - </View> - ) : ( - <FlatList - style={[!isTabletOrDesktop && styles.flex1]} - data={mutedAccounts.mutes} - keyExtractor={item => item.did} - refreshControl={ - <RefreshControl - refreshing={mutedAccounts.isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - onEndReached={onEndReached} - renderItem={renderItem} - initialNumToRender={15} - // FIXME(dan) - // eslint-disable-next-line react/no-unstable-nested-components - ListFooterComponent={() => ( - <View style={styles.footer}> - {mutedAccounts.isLoading && <ActivityIndicator />} - </View> - )} - extraData={mutedAccounts.isLoading} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> - )} - </CenteredView> - ) - }), -) + )} + </View> + ) : ( + <FlatList + style={[!isTabletOrDesktop && styles.flex1]} + data={profiles} + keyExtractor={item => item.did} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onEndReached={onEndReached} + renderItem={renderItem} + initialNumToRender={15} + // FIXME(dan) + + ListFooterComponent={() => ( + <View style={styles.footer}> + {(isFetching || isFetchingNextPage) && <ActivityIndicator />} + </View> + )} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + )} + </CenteredView> + ) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/NotFound.tsx b/src/view/screens/NotFound.tsx index c2125756c..2508a9ed2 100644 --- a/src/view/screens/NotFound.tsx +++ b/src/view/screens/NotFound.tsx @@ -12,9 +12,12 @@ import {NavigationProp} from 'lib/routes/types' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export const NotFoundScreen = () => { const pal = usePalette('default') + const {_} = useLingui() const navigation = useNavigation<NavigationProp>() const setMinimalShellMode = useSetMinimalShellMode() @@ -36,13 +39,15 @@ export const NotFoundScreen = () => { return ( <View testID="notFoundView" style={pal.view}> - <ViewHeader title="Page not found" /> + <ViewHeader title={_(msg`Page not found`)} /> <View style={styles.container}> <Text type="title-2xl" style={[pal.text, s.mb10]}> - Page not found + <Trans>Page not found</Trans> </Text> <Text type="md" style={[pal.text, s.mb10]}> - We're sorry! We can't find the page you were looking for. + <Trans> + We're sorry! We can't find the page you were looking for. + </Trans> </Text> <Button type="primary" diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index cd482bd1c..3ce1128a6 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -1,166 +1,135 @@ import React from 'react' import {FlatList, View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' -import {observer} from 'mobx-react-lite' +import {useQueryClient} from '@tanstack/react-query' import { NativeStackScreenProps, NotificationsTabNavigatorParams, } from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {Feed} from '../com/notifications/Feed' import {TextLink} from 'view/com/util/Link' -import {InvitedUsers} from '../com/notifications/InvitedUsers' import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' -import {useStores} from 'state/index' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' -import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' -import {isWeb} from 'platform/detection' import {logger} from '#/logger' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + useUnreadNotifications, + useUnreadNotificationsApi, +} from '#/state/queries/notifications/unread' +import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed' +import {listenSoftReset, emitSoftReset} from '#/state/events' +import {truncateAndInvalidate} from '#/state/queries/util' type Props = NativeStackScreenProps< NotificationsTabNavigatorParams, 'Notifications' > -export const NotificationsScreen = withAuthRequired( - observer(function NotificationsScreenImpl({}: Props) { - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll() - const scrollElRef = React.useRef<FlatList>(null) - const {screen} = useAnalytics() - const pal = usePalette('default') - const {isDesktop} = useWebMediaQueries() - - const hasNew = - store.me.notifications.hasNewLatest && - !store.me.notifications.isRefreshing - - // event handlers - // = - const onPressTryAgain = React.useCallback(() => { - store.me.notifications.refresh() - }, [store]) - - const scrollToTop = React.useCallback(() => { - scrollElRef.current?.scrollToOffset({offset: 0}) - resetMainScroll() - }, [scrollElRef, resetMainScroll]) +export function NotificationsScreen({}: Props) { + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll() + const scrollElRef = React.useRef<FlatList>(null) + const {screen} = useAnalytics() + const pal = usePalette('default') + const {isDesktop} = useWebMediaQueries() + const queryClient = useQueryClient() + const unreadNotifs = useUnreadNotifications() + const unreadApi = useUnreadNotificationsApi() + const hasNew = !!unreadNotifs - const onPressLoadLatest = React.useCallback(() => { - scrollToTop() - store.me.notifications.refresh() - }, [store, scrollToTop]) + // event handlers + // = + const scrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: 0}) + resetMainScroll() + }, [scrollElRef, resetMainScroll]) - // on-visible setup - // = - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - logger.debug('NotificationsScreen: Updating feed') - const softResetSub = store.onScreenSoftReset(onPressLoadLatest) - store.me.notifications.update() - screen('Notifications') + const onPressLoadLatest = React.useCallback(() => { + scrollToTop() + if (hasNew) { + // render what we have now + truncateAndInvalidate(queryClient, NOTIFS_RQKEY()) + } else { + // check with the server + unreadApi.checkUnread({invalidate: true}) + } + }, [scrollToTop, queryClient, unreadApi, hasNew]) - return () => { - softResetSub.remove() - store.me.notifications.markAllRead() - } - }, [store, screen, onPressLoadLatest, setMinimalShellMode]), - ) + // on-visible setup + // = + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + logger.debug('NotificationsScreen: Updating feed') + screen('Notifications') + return listenSoftReset(onPressLoadLatest) + }, [screen, onPressLoadLatest, setMinimalShellMode]), + ) - useTabFocusEffect( - 'Notifications', - React.useCallback( - isInside => { - // on mobile: - // fires with `isInside=true` when the user navigates to the root tab - // but not when the user goes back to the screen by pressing back - // on web: - // essentially equivalent to useFocusEffect because we dont used tabbed - // navigation - if (isInside) { - if (isWeb) { - store.me.notifications.syncQueue() - } else { - if (store.me.notifications.unreadCount > 0) { - store.me.notifications.refresh() - } else { - store.me.notifications.syncQueue() - } + const ListHeaderComponent = React.useCallback(() => { + if (isDesktop) { + return ( + <View + style={[ + pal.view, + { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 18, + paddingVertical: 12, + }, + ]}> + <TextLink + type="title-lg" + href="/notifications" + style={[pal.text, {fontWeight: 'bold'}]} + text={ + <> + <Trans>Notifications</Trans>{' '} + {hasNew && ( + <View + style={{ + top: -8, + backgroundColor: colors.blue3, + width: 8, + height: 8, + borderRadius: 4, + }} + /> + )} + </> } - } - }, - [store], - ), - ) - - const ListHeaderComponent = React.useCallback(() => { - if (isDesktop) { - return ( - <View - style={[ - pal.view, - { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 18, - paddingVertical: 12, - }, - ]}> - <TextLink - type="title-lg" - href="/notifications" - style={[pal.text, {fontWeight: 'bold'}]} - text={ - <> - Notifications{' '} - {hasNew && ( - <View - style={{ - top: -8, - backgroundColor: colors.blue3, - width: 8, - height: 8, - borderRadius: 4, - }} - /> - )} - </> - } - onPress={() => store.emitScreenSoftReset()} - /> - </View> - ) - } - return <></> - }, [isDesktop, pal, store, hasNew]) + onPress={emitSoftReset} + /> + </View> + ) + } + return <></> + }, [isDesktop, pal, hasNew]) - return ( - <View testID="notificationsScreen" style={s.hContentRegion}> - <ViewHeader title="Notifications" canGoBack={false} /> - <InvitedUsers /> - <Feed - view={store.me.notifications} - onPressTryAgain={onPressTryAgain} - onScroll={onMainScroll} - scrollElRef={scrollElRef} - ListHeaderComponent={ListHeaderComponent} + return ( + <View testID="notificationsScreen" style={s.hContentRegion}> + <ViewHeader title={_(msg`Notifications`)} canGoBack={false} /> + <Feed + onScroll={onMainScroll} + scrollElRef={scrollElRef} + ListHeaderComponent={ListHeaderComponent} + /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onPressLoadLatest} + label={_(msg`Load new notifications`)} + showIndicator={hasNew} /> - {(isScrolledDown || hasNew) && ( - <LoadLatestBtn - onPress={onPressLoadLatest} - label="Load new notifications" - showIndicator={hasNew} - /> - )} - </View> - ) - }), -) + )} + </View> + ) +} diff --git a/src/view/screens/PostLikedBy.tsx b/src/view/screens/PostLikedBy.tsx index 2f45908b3..7cbb81102 100644 --- a/src/view/screens/PostLikedBy.tsx +++ b/src/view/screens/PostLikedBy.tsx @@ -2,17 +2,19 @@ import React from 'react' import {View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' import {makeRecordUri} from 'lib/strings/url-helpers' import {useSetMinimalShellMode} from '#/state/shell' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'> -export const PostLikedByScreen = withAuthRequired(({route}: Props) => { +export const PostLikedByScreen = ({route}: Props) => { const setMinimalShellMode = useSetMinimalShellMode() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -22,8 +24,8 @@ export const PostLikedByScreen = withAuthRequired(({route}: Props) => { return ( <View> - <ViewHeader title="Liked by" /> + <ViewHeader title={_(msg`Liked by`)} /> <PostLikedByComponent uri={uri} /> </View> ) -}) +} diff --git a/src/view/screens/PostRepostedBy.tsx b/src/view/screens/PostRepostedBy.tsx index abe03467a..de95f33bf 100644 --- a/src/view/screens/PostRepostedBy.tsx +++ b/src/view/screens/PostRepostedBy.tsx @@ -1,18 +1,20 @@ import React from 'react' import {View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {ViewHeader} from '../com/util/ViewHeader' import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy' import {makeRecordUri} from 'lib/strings/url-helpers' import {useSetMinimalShellMode} from '#/state/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'> -export const PostRepostedByScreen = withAuthRequired(({route}: Props) => { +export const PostRepostedByScreen = ({route}: Props) => { const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -22,8 +24,8 @@ export const PostRepostedByScreen = withAuthRequired(({route}: Props) => { return ( <View> - <ViewHeader title="Reposted by" /> + <ViewHeader title={_(msg`Reposted by`)} /> <PostRepostedByComponent uri={uri} /> </View> ) -}) +} diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index 0bdd06269..4b1f51748 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -1,104 +1,107 @@ -import React, {useMemo} from 'react' -import {InteractionManager, StyleSheet, View} from 'react-native' +import React from 'react' +import {StyleSheet, View} from 'react-native' +import Animated from 'react-native-reanimated' import {useFocusEffect} from '@react-navigation/native' -import {observer} from 'mobx-react-lite' +import {useQueryClient} from '@tanstack/react-query' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {makeRecordUri} from 'lib/strings/url-helpers' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread' import {ComposePrompt} from 'view/com/composer/Prompt' -import {PostThreadModel} from 'state/models/content/post-thread' -import {useStores} from 'state/index' import {s} from 'lib/styles' import {useSafeAreaInsets} from 'react-native-safe-area-context' +import { + RQKEY as POST_THREAD_RQKEY, + ThreadNode, +} from '#/state/queries/post-thread' import {clamp} from 'lodash' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {logger} from '#/logger' -import {useMinimalShellMode, useSetMinimalShellMode} from '#/state/shell' - -const SHELL_FOOTER_HEIGHT = 44 +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import {useSetMinimalShellMode} from '#/state/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {ErrorMessage} from '../com/util/error/ErrorMessage' +import {CenteredView} from '../com/util/Views' +import {useComposerControls} from '#/state/shell/composer' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'> -export const PostThreadScreen = withAuthRequired( - observer(function PostThreadScreenImpl({route}: Props) { - const store = useStores() - const minimalShellMode = useMinimalShellMode() - const setMinimalShellMode = useSetMinimalShellMode() - const safeAreaInsets = useSafeAreaInsets() - const {name, rkey} = route.params - const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) - const view = useMemo<PostThreadModel>( - () => new PostThreadModel(store, {uri}), - [store, uri], - ) - const {isMobile} = useWebMediaQueries() - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - const threadCleanup = view.registerListeners() +export function PostThreadScreen({route}: Props) { + const queryClient = useQueryClient() + const {_} = useLingui() + const {fabMinimalShellTransform} = useMinimalShellMode() + const setMinimalShellMode = useSetMinimalShellMode() + const {openComposer} = useComposerControls() + const safeAreaInsets = useSafeAreaInsets() + const {name, rkey} = route.params + const {isMobile} = useWebMediaQueries() + const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) + const {data: resolvedUri, error: uriError} = useResolveUriQuery(uri) - InteractionManager.runAfterInteractions(() => { - if (!view.hasLoaded && !view.isLoading) { - view.setup().catch(err => { - logger.error('Failed to fetch thread', {error: err}) - }) - } - }) + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) - return () => { - threadCleanup() - } - }, [view, setMinimalShellMode]), + const onPressReply = React.useCallback(() => { + if (!resolvedUri) { + return + } + const thread = queryClient.getQueryData<ThreadNode>( + POST_THREAD_RQKEY(resolvedUri.uri), ) - - const onPressReply = React.useCallback(() => { - if (!view.thread) { - return - } - store.shell.openComposer({ - replyTo: { - uri: view.thread.post.uri, - cid: view.thread.post.cid, - text: view.thread.postRecord?.text as string, - author: { - handle: view.thread.post.author.handle, - displayName: view.thread.post.author.displayName, - avatar: view.thread.post.author.avatar, - }, + if (thread?.type !== 'post') { + return + } + openComposer({ + replyTo: { + uri: thread.post.uri, + cid: thread.post.cid, + text: thread.record.text, + author: { + handle: thread.post.author.handle, + displayName: thread.post.author.displayName, + avatar: thread.post.author.avatar, }, - onPost: () => view.refresh(), - }) - }, [view, store]) + }, + onPost: () => + queryClient.invalidateQueries({ + queryKey: POST_THREAD_RQKEY(resolvedUri.uri || ''), + }), + }) + }, [openComposer, queryClient, resolvedUri]) - return ( - <View style={s.hContentRegion}> - {isMobile && <ViewHeader title="Post" />} - <View style={s.flex1}> + return ( + <View style={s.hContentRegion}> + {isMobile && <ViewHeader title={_(msg`Post`)} />} + <View style={s.flex1}> + {uriError ? ( + <CenteredView> + <ErrorMessage message={String(uriError)} /> + </CenteredView> + ) : ( <PostThreadComponent - uri={uri} - view={view} + uri={resolvedUri?.uri} onPressReply={onPressReply} - treeView={!!store.preferences.thread.lab_treeViewEnabled} /> - </View> - {isMobile && !minimalShellMode && ( - <View - style={[ - styles.prompt, - { - bottom: - SHELL_FOOTER_HEIGHT + clamp(safeAreaInsets.bottom, 15, 30), - }, - ]}> - <ComposePrompt onPressCompose={onPressReply} /> - </View> )} </View> - ) - }), -) + {isMobile && ( + <Animated.View + style={[ + styles.prompt, + fabMinimalShellTransform, + { + bottom: clamp(safeAreaInsets.bottom, 15, 30), + }, + ]}> + <ComposePrompt onPressCompose={onPressReply} /> + </Animated.View> + )} + </View> + ) +} const styles = StyleSheet.create({ prompt: { diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx index 21c15931f..fe17be5e8 100644 --- a/src/view/screens/PreferencesHomeFeed.tsx +++ b/src/view/screens/PreferencesHomeFeed.tsx @@ -1,10 +1,8 @@ import React, {useState} from 'react' import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Slider} from '@miblanchard/react-native-slider' import {Text} from '../com/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' @@ -14,21 +12,33 @@ import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {ViewHeader} from 'view/com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' import debounce from 'lodash.debounce' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + usePreferencesQuery, + useSetFeedViewPreferencesMutation, +} from '#/state/queries/preferences' -function RepliesThresholdInput({enabled}: {enabled: boolean}) { - const store = useStores() +function RepliesThresholdInput({ + enabled, + initialValue, +}: { + enabled: boolean + initialValue: number +}) { const pal = usePalette('default') - const [value, setValue] = useState( - store.preferences.homeFeed.hideRepliesByLikeCount, - ) + const [value, setValue] = useState(initialValue) + const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation() const save = React.useMemo( () => debounce( threshold => - store.preferences.setHomeFeedHideRepliesByLikeCount(threshold), + setFeedViewPref({ + hideRepliesByLikeCount: threshold, + }), 500, ), // debouce for 500ms - [store], + [setFeedViewPref], ) return ( @@ -61,12 +71,17 @@ type Props = NativeStackScreenProps< CommonNavigatorParams, 'PreferencesHomeFeed' > -export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ - navigation, -}: Props) { +export function PreferencesHomeFeed({navigation}: Props) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() const {isTabletOrDesktop} = useWebMediaQueries() + const {data: preferences} = usePreferencesQuery() + const {mutate: setFeedViewPref, variables} = + useSetFeedViewPreferencesMutation() + + const showReplies = !( + variables?.hideReplies ?? preferences?.feedViewPrefs?.hideReplies + ) return ( <CenteredView @@ -77,14 +92,14 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ styles.container, isTabletOrDesktop && styles.desktopContainer, ]}> - <ViewHeader title="Home Feed Preferences" showOnDesktop /> + <ViewHeader title={_(msg`Home Feed Preferences`)} showOnDesktop /> <View style={[ styles.titleSection, isTabletOrDesktop && {paddingTop: 20, paddingBottom: 20}, ]}> <Text type="xl" style={[pal.textLight, styles.description]}> - Fine-tune the content you see on your home screen. + <Trans>Fine-tune the content you see on your home screen.</Trans> </Text> </View> @@ -92,98 +107,175 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ <View style={styles.cardsContainer}> <View style={[pal.viewLight, styles.card]}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Show Replies + <Trans>Show Replies</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Set this setting to "No" to hide all replies from your feed. + <Trans> + Set this setting to "No" to hide all replies from your feed. + </Trans> </Text> <ToggleButton testID="toggleRepliesBtn" type="default-light" - label={store.preferences.homeFeed.hideReplies ? 'No' : 'Yes'} - isSelected={!store.preferences.homeFeed.hideReplies} - onPress={store.preferences.toggleHomeFeedHideReplies} + label={showReplies ? 'Yes' : 'No'} + isSelected={showReplies} + onPress={() => + setFeedViewPref({ + hideReplies: !( + variables?.hideReplies ?? + preferences?.feedViewPrefs?.hideReplies + ), + }) + } /> </View> <View - style={[ - pal.viewLight, - styles.card, - store.preferences.homeFeed.hideReplies && styles.dimmed, - ]}> + style={[pal.viewLight, styles.card, !showReplies && styles.dimmed]}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Reply Filters + <Trans>Reply Filters</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Enable this setting to only see replies between people you follow. + <Trans> + Enable this setting to only see replies between people you + follow. + </Trans> </Text> <ToggleButton type="default-light" - label="Followed users only" - isSelected={store.preferences.homeFeed.hideRepliesByUnfollowed} + label={_(msg`Followed users only`)} + isSelected={Boolean( + variables?.hideRepliesByUnfollowed ?? + preferences?.feedViewPrefs?.hideRepliesByUnfollowed, + )} onPress={ - !store.preferences.homeFeed.hideReplies - ? store.preferences.toggleHomeFeedHideRepliesByUnfollowed + showReplies + ? () => + setFeedViewPref({ + hideRepliesByUnfollowed: !( + variables?.hideRepliesByUnfollowed ?? + preferences?.feedViewPrefs?.hideRepliesByUnfollowed + ), + }) : undefined } style={[s.mb10]} /> <Text style={[pal.text]}> - Adjust the number of likes a reply must have to be shown in your - feed. + <Trans> + Adjust the number of likes a reply must have to be shown in your + feed. + </Trans> </Text> - <RepliesThresholdInput - enabled={!store.preferences.homeFeed.hideReplies} - /> + {preferences && ( + <RepliesThresholdInput + enabled={showReplies} + initialValue={preferences.feedViewPrefs.hideRepliesByLikeCount} + /> + )} </View> <View style={[pal.viewLight, styles.card]}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Show Reposts + <Trans>Show Reposts</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Set this setting to "No" to hide all reposts from your feed. + <Trans> + Set this setting to "No" to hide all reposts from your feed. + </Trans> </Text> <ToggleButton type="default-light" - label={store.preferences.homeFeed.hideReposts ? 'No' : 'Yes'} - isSelected={!store.preferences.homeFeed.hideReposts} - onPress={store.preferences.toggleHomeFeedHideReposts} + label={ + variables?.hideReposts ?? + preferences?.feedViewPrefs?.hideReposts + ? _(msg`No`) + : _(msg`Yes`) + } + isSelected={ + !( + variables?.hideReposts ?? + preferences?.feedViewPrefs?.hideReposts + ) + } + onPress={() => + setFeedViewPref({ + hideReposts: !( + variables?.hideReposts ?? + preferences?.feedViewPrefs?.hideReposts + ), + }) + } /> </View> <View style={[pal.viewLight, styles.card]}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Show Quote Posts + <Trans>Show Quote Posts</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Set this setting to "No" to hide all quote posts from your feed. - Reposts will still be visible. + <Trans> + Set this setting to "No" to hide all quote posts from your feed. + Reposts will still be visible. + </Trans> </Text> <ToggleButton type="default-light" - label={store.preferences.homeFeed.hideQuotePosts ? 'No' : 'Yes'} - isSelected={!store.preferences.homeFeed.hideQuotePosts} - onPress={store.preferences.toggleHomeFeedHideQuotePosts} + label={ + variables?.hideQuotePosts ?? + preferences?.feedViewPrefs?.hideQuotePosts + ? _(msg`No`) + : _(msg`Yes`) + } + isSelected={ + !( + variables?.hideQuotePosts ?? + preferences?.feedViewPrefs?.hideQuotePosts + ) + } + onPress={() => + setFeedViewPref({ + hideQuotePosts: !( + variables?.hideQuotePosts ?? + preferences?.feedViewPrefs?.hideQuotePosts + ), + }) + } /> </View> <View style={[pal.viewLight, styles.card]}> <Text type="title-sm" style={[pal.text, s.pb5]}> - <FontAwesomeIcon icon="flask" color={pal.colors.text} /> Show - Posts from My Feeds + <FontAwesomeIcon icon="flask" color={pal.colors.text} /> + <Trans>Show Posts from My Feeds</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Set this setting to "Yes" to show samples of your saved feeds in - your following feed. This is an experimental feature. + <Trans> + Set this setting to "Yes" to show samples of your saved feeds in + your following feed. This is an experimental feature. + </Trans> </Text> <ToggleButton type="default-light" label={ - store.preferences.homeFeed.lab_mergeFeedEnabled ? 'Yes' : 'No' + variables?.lab_mergeFeedEnabled ?? + preferences?.feedViewPrefs?.lab_mergeFeedEnabled + ? _(msg`Yes`) + : _(msg`No`) + } + isSelected={ + !!( + variables?.lab_mergeFeedEnabled ?? + preferences?.feedViewPrefs?.lab_mergeFeedEnabled + ) + } + onPress={() => + setFeedViewPref({ + lab_mergeFeedEnabled: !( + variables?.lab_mergeFeedEnabled ?? + preferences?.feedViewPrefs?.lab_mergeFeedEnabled + ), + }) } - isSelected={!!store.preferences.homeFeed.lab_mergeFeedEnabled} - onPress={store.preferences.toggleHomeFeedMergeFeedEnabled} /> </View> </View> @@ -204,14 +296,16 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ }} style={[styles.btn, isTabletOrDesktop && styles.btnDesktop]} 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> </CenteredView> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/PreferencesThreads.tsx b/src/view/screens/PreferencesThreads.tsx index af98a1833..73d941932 100644 --- a/src/view/screens/PreferencesThreads.tsx +++ b/src/view/screens/PreferencesThreads.tsx @@ -1,9 +1,13 @@ import React from 'react' -import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' +import { + ActivityIndicator, + ScrollView, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../com/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' @@ -12,14 +16,30 @@ import {RadioGroup} from 'view/com/util/forms/RadioGroup' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {ViewHeader} from 'view/com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + usePreferencesQuery, + useSetThreadViewPreferencesMutation, +} from '#/state/queries/preferences' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'> -export const PreferencesThreads = observer(function PreferencesThreadsImpl({ - navigation, -}: Props) { +export function PreferencesThreads({navigation}: Props) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() const {isTabletOrDesktop} = useWebMediaQueries() + const {data: preferences} = usePreferencesQuery() + const {mutate: setThreadViewPrefs, variables} = + useSetThreadViewPreferencesMutation() + + const prioritizeFollowedUsers = Boolean( + variables?.prioritizeFollowedUsers ?? + preferences?.threadViewPrefs?.prioritizeFollowedUsers, + ) + const treeViewEnabled = Boolean( + variables?.lab_treeViewEnabled ?? + preferences?.threadViewPrefs?.lab_treeViewEnabled, + ) return ( <CenteredView @@ -30,78 +50,90 @@ export const PreferencesThreads = observer(function PreferencesThreadsImpl({ styles.container, isTabletOrDesktop && styles.desktopContainer, ]}> - <ViewHeader title="Thread Preferences" showOnDesktop /> + <ViewHeader title={_(msg`Thread Preferences`)} showOnDesktop /> <View style={[ styles.titleSection, isTabletOrDesktop && {paddingTop: 20, paddingBottom: 20}, ]}> <Text type="xl" style={[pal.textLight, styles.description]}> - Fine-tune the discussion threads. + <Trans>Fine-tune the discussion threads.</Trans> </Text> </View> - <ScrollView> - <View style={styles.cardsContainer}> - <View style={[pal.viewLight, styles.card]}> - <Text type="title-sm" style={[pal.text, s.pb5]}> - Sort Replies - </Text> - <Text style={[pal.text, s.pb10]}> - Sort replies to the same post by: - </Text> - <View style={[pal.view, {borderRadius: 8, paddingVertical: 6}]}> - <RadioGroup + {preferences ? ( + <ScrollView> + <View style={styles.cardsContainer}> + <View style={[pal.viewLight, styles.card]}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + <Trans>Sort Replies</Trans> + </Text> + <Text style={[pal.text, s.pb10]}> + <Trans>Sort replies to the same post by:</Trans> + </Text> + <View style={[pal.view, {borderRadius: 8, paddingVertical: 6}]}> + <RadioGroup + type="default-light" + items={[ + {key: 'oldest', label: 'Oldest replies first'}, + {key: 'newest', label: 'Newest replies first'}, + {key: 'most-likes', label: 'Most-liked replies first'}, + {key: 'random', label: 'Random (aka "Poster\'s Roulette")'}, + ]} + onSelect={key => setThreadViewPrefs({sort: key})} + initialSelection={preferences?.threadViewPrefs?.sort} + /> + </View> + </View> + + <View style={[pal.viewLight, styles.card]}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + <Trans>Prioritize Your Follows</Trans> + </Text> + <Text style={[pal.text, s.pb10]}> + <Trans> + Show replies by people you follow before all other replies. + </Trans> + </Text> + <ToggleButton type="default-light" - items={[ - {key: 'oldest', label: 'Oldest replies first'}, - {key: 'newest', label: 'Newest replies first'}, - {key: 'most-likes', label: 'Most-liked replies first'}, - {key: 'random', label: 'Random (aka "Poster\'s Roulette")'}, - ]} - onSelect={store.preferences.setThreadSort} - initialSelection={store.preferences.thread.sort} + label={prioritizeFollowedUsers ? 'Yes' : 'No'} + isSelected={prioritizeFollowedUsers} + onPress={() => + setThreadViewPrefs({ + prioritizeFollowedUsers: !prioritizeFollowedUsers, + }) + } /> </View> - </View> - <View style={[pal.viewLight, styles.card]}> - <Text type="title-sm" style={[pal.text, s.pb5]}> - Prioritize Your Follows - </Text> - <Text style={[pal.text, s.pb10]}> - Show replies by people you follow before all other replies. - </Text> - <ToggleButton - type="default-light" - label={ - store.preferences.thread.prioritizeFollowedUsers ? 'Yes' : 'No' - } - isSelected={store.preferences.thread.prioritizeFollowedUsers} - onPress={store.preferences.togglePrioritizedFollowedUsers} - /> - </View> - - <View style={[pal.viewLight, styles.card]}> - <Text type="title-sm" style={[pal.text, s.pb5]}> - <FontAwesomeIcon icon="flask" color={pal.colors.text} /> Threaded - Mode - </Text> - <Text style={[pal.text, s.pb10]}> - Set this setting to "Yes" to show replies in a threaded view. This - is an experimental feature. - </Text> - <ToggleButton - type="default-light" - label={ - store.preferences.thread.lab_treeViewEnabled ? 'Yes' : 'No' - } - isSelected={!!store.preferences.thread.lab_treeViewEnabled} - onPress={store.preferences.toggleThreadTreeViewEnabled} - /> + <View style={[pal.viewLight, styles.card]}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + <FontAwesomeIcon icon="flask" color={pal.colors.text} />{' '} + <Trans>Threaded Mode</Trans> + </Text> + <Text style={[pal.text, s.pb10]}> + <Trans> + Set this setting to "Yes" to show replies in a threaded view. + This is an experimental feature. + </Trans> + </Text> + <ToggleButton + type="default-light" + label={treeViewEnabled ? 'Yes' : 'No'} + isSelected={treeViewEnabled} + onPress={() => + setThreadViewPrefs({ + lab_treeViewEnabled: !treeViewEnabled, + }) + } + /> + </View> </View> - </View> - </ScrollView> + </ScrollView> + ) : ( + <ActivityIndicator /> + )} <View style={[ @@ -118,14 +150,16 @@ export const PreferencesThreads = observer(function PreferencesThreadsImpl({ }} style={[styles.btn, isTabletOrDesktop && styles.btnDesktop]} 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> </CenteredView> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/PrivacyPolicy.tsx b/src/view/screens/PrivacyPolicy.tsx index f709c9fda..247afc316 100644 --- a/src/view/screens/PrivacyPolicy.tsx +++ b/src/view/screens/PrivacyPolicy.tsx @@ -9,10 +9,13 @@ import {ScrollView} from 'view/com/util/Views' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PrivacyPolicy'> export const PrivacyPolicyScreen = (_props: Props) => { const pal = usePalette('default') + const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( @@ -23,16 +26,18 @@ export const PrivacyPolicyScreen = (_props: Props) => { return ( <View> - <ViewHeader title="Privacy Policy" /> + <ViewHeader title={_(msg`Privacy Policy`)} /> <ScrollView style={[s.hContentRegion, pal.view]}> <View style={[s.p20]}> <Text style={pal.text}> - The Privacy Policy has been moved to{' '} - <TextLink - style={pal.link} - href="https://blueskyweb.xyz/support/privacy-policy" - text="blueskyweb.xyz/support/privacy-policy" - /> + <Trans> + The Privacy Policy has been moved to{' '} + <TextLink + style={pal.link} + href="https://blueskyweb.xyz/support/privacy-policy" + text="blueskyweb.xyz/support/privacy-policy" + /> + </Trans> </Text> </View> <View style={s.footerSpacer} /> diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 9a25612ad..4af1b650e 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,317 +1,447 @@ -import React, {useEffect, useState} from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' +import React, {useMemo} from 'react' +import {StyleSheet, View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' +import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ViewSelector, ViewSelectorHandle} from '../com/util/ViewSelector' -import {CenteredView} from '../com/util/Views' +import {CenteredView, FlatList} from '../com/util/Views' import {ScreenHider} from 'view/com/util/moderation/ScreenHider' -import {ProfileUiModel, Sections} from 'state/models/ui/profile' -import {useStores} from 'state/index' -import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice' +import {Feed} from 'view/com/posts/Feed' +import {ProfileLists} from '../com/lists/ProfileLists' +import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' import {ProfileHeader} from '../com/profile/ProfileHeader' -import {FeedSlice} from '../com/posts/FeedSlice' -import {ListCard} from 'view/com/lists/ListCard' -import { - PostFeedLoadingPlaceholder, - ProfileCardFeedLoadingPlaceholder, -} from '../com/util/LoadingPlaceholder' +import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ErrorScreen} from '../com/util/error/ErrorScreen' -import {ErrorMessage} from '../com/util/error/ErrorMessage' import {EmptyState} from '../com/util/EmptyState' -import {Text} from '../com/util/text/Text' import {FAB} from '../com/util/fab/FAB' import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' import {ComposeIcon2} from 'lib/icons' -import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' -import {FeedSourceModel} from 'state/models/content/feed-source' import {useSetTitle} from 'lib/hooks/useSetTitle' import {combinedDisplayName} from 'lib/strings/display-names' -import {logger} from '#/logger' -import {useSetMinimalShellMode} from '#/state/shell' +import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll' +import {FeedDescriptor} from '#/state/queries/post-feed' +import {useResolveDidQuery} from '#/state/queries/resolve-uri' +import {useProfileQuery} from '#/state/queries/profile' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useSession} from '#/state/session' +import {useModerationOpts} from '#/state/queries/preferences' +import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' +import {cleanError} from '#/lib/strings/errors' +import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' +import {useQueryClient} from '@tanstack/react-query' +import {useComposerControls} from '#/state/shell/composer' +import {listenSoftReset} from '#/state/events' +import {truncateAndInvalidate} from '#/state/queries/util' + +interface SectionRef { + scrollToTop: () => void +} type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> -export const ProfileScreen = withAuthRequired( - observer(function ProfileScreenImpl({route}: Props) { - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {screen, track} = useAnalytics() - const viewSelectorRef = React.useRef<ViewSelectorHandle>(null) - const name = route.params.name === 'me' ? store.me.did : route.params.name +export function ProfileScreen({route}: Props) { + const {currentAccount} = useSession() + const name = + route.params.name === 'me' ? currentAccount?.did : route.params.name + const moderationOpts = useModerationOpts() + const { + data: resolvedDid, + error: resolveError, + refetch: refetchDid, + isInitialLoading: isInitialLoadingDid, + } = useResolveDidQuery(name) + const { + data: profile, + error: profileError, + refetch: refetchProfile, + isInitialLoading: isInitialLoadingProfile, + } = useProfileQuery({ + did: resolvedDid, + }) - useEffect(() => { - screen('Profile') - }, [screen]) + const onPressTryAgain = React.useCallback(() => { + if (resolveError) { + refetchDid() + } else { + refetchProfile() + } + }, [resolveError, refetchDid, refetchProfile]) - const [hasSetup, setHasSetup] = useState<boolean>(false) - const uiState = React.useMemo( - () => new ProfileUiModel(store, {user: name}), - [name, store], + if (isInitialLoadingDid || isInitialLoadingProfile || !moderationOpts) { + return ( + <CenteredView> + <ProfileHeader + profile={null} + moderation={null} + isProfilePreview={true} + /> + </CenteredView> ) - useSetTitle(combinedDisplayName(uiState.profile)) + } + if (resolveError || profileError) { + return ( + <CenteredView> + <ErrorScreen + testID="profileErrorScreen" + title="Oops!" + message={cleanError(resolveError || profileError)} + onPressTryAgain={onPressTryAgain} + /> + </CenteredView> + ) + } + if (profile && moderationOpts) { + return ( + <ProfileScreenLoaded + profile={profile} + moderationOpts={moderationOpts} + hideBackButton={!!route.params.hideBackButton} + /> + ) + } + // should never happen + return ( + <CenteredView> + <ErrorScreen + testID="profileErrorScreen" + title="Oops!" + message="Something went wrong and we're not sure what." + onPressTryAgain={onPressTryAgain} + /> + </CenteredView> + ) +} - const onSoftReset = React.useCallback(() => { - viewSelectorRef.current?.scrollToTop() - }, []) +function ProfileScreenLoaded({ + profile: profileUnshadowed, + moderationOpts, + hideBackButton, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed + moderationOpts: ModerationOpts + hideBackButton: boolean +}) { + const profile = useProfileShadow(profileUnshadowed) + const {hasSession, currentAccount} = useSession() + const setMinimalShellMode = useSetMinimalShellMode() + const {openComposer} = useComposerControls() + const {screen, track} = useAnalytics() + const [currentPage, setCurrentPage] = React.useState(0) + const {_} = useLingui() + const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + const extraInfoQuery = useProfileExtraInfoQuery(profile.did) + const postsSectionRef = React.useRef<SectionRef>(null) + const repliesSectionRef = React.useRef<SectionRef>(null) + const mediaSectionRef = React.useRef<SectionRef>(null) + const likesSectionRef = React.useRef<SectionRef>(null) + const feedsSectionRef = React.useRef<SectionRef>(null) + const listsSectionRef = React.useRef<SectionRef>(null) - useEffect(() => { - setHasSetup(false) - }, [name]) + useSetTitle(combinedDisplayName(profile)) - // We don't need this to be reactive, so we can just register the listeners once - useEffect(() => { - const listCleanup = uiState.lists.registerListeners() - return () => listCleanup() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + const moderation = useMemo( + () => moderateProfile(profile, moderationOpts), + [profile, moderationOpts], + ) - useFocusEffect( - React.useCallback(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) - let aborted = false - setMinimalShellMode(false) - const feedCleanup = uiState.feed.registerListeners() - if (!hasSetup) { - uiState.setup().then(() => { - if (aborted) { - return - } - setHasSetup(true) - }) - } - return () => { - aborted = true - feedCleanup() - softResetSub.remove() - } - }, [store, onSoftReset, uiState, hasSetup, setMinimalShellMode]), - ) + const isMe = profile.did === currentAccount?.did + const showRepliesTab = hasSession + const showLikesTab = isMe + const showFeedsTab = isMe || extraInfoQuery.data?.hasFeedgens + const showListsTab = hasSession && (isMe || extraInfoQuery.data?.hasLists) + const sectionTitles = useMemo<string[]>(() => { + return [ + 'Posts', + showRepliesTab ? 'Posts & Replies' : undefined, + 'Media', + showLikesTab ? 'Likes' : undefined, + showFeedsTab ? 'Feeds' : undefined, + showListsTab ? 'Lists' : undefined, + ].filter(Boolean) as string[] + }, [showRepliesTab, showLikesTab, showFeedsTab, showListsTab]) - // events - // = + let nextIndex = 0 + const postsIndex = nextIndex++ + let repliesIndex: number | null = null + if (showRepliesTab) { + repliesIndex = nextIndex++ + } + const mediaIndex = nextIndex++ + let likesIndex: number | null = null + if (showLikesTab) { + likesIndex = nextIndex++ + } + let feedsIndex: number | null = null + if (showFeedsTab) { + feedsIndex = nextIndex++ + } + let listsIndex: number | null = null + if (showListsTab) { + listsIndex = nextIndex++ + } - const onPressCompose = React.useCallback(() => { - track('ProfileScreen:PressCompose') - const mention = - uiState.profile.handle === store.me.handle || - uiState.profile.handle === 'handle.invalid' - ? undefined - : uiState.profile.handle - store.shell.openComposer({mention}) - }, [store, track, uiState]) - const onSelectView = React.useCallback( - (index: number) => { - uiState.setSelectedViewIndex(index) - }, - [uiState], - ) - const onRefresh = React.useCallback(() => { - uiState - .refresh() - .catch((err: any) => - logger.error('Failed to refresh user profile', {error: err}), - ) - }, [uiState]) - const onEndReached = React.useCallback(() => { - uiState.loadMore().catch((err: any) => - logger.error('Failed to load more entries in user profile', { - error: err, - }), - ) - }, [uiState]) - const onPressTryAgain = React.useCallback(() => { - uiState.setup() - }, [uiState]) + const scrollSectionToTop = React.useCallback( + (index: number) => { + if (index === postsIndex) { + postsSectionRef.current?.scrollToTop() + } else if (index === repliesIndex) { + repliesSectionRef.current?.scrollToTop() + } else if (index === mediaIndex) { + mediaSectionRef.current?.scrollToTop() + } else if (index === likesIndex) { + likesSectionRef.current?.scrollToTop() + } else if (index === feedsIndex) { + feedsSectionRef.current?.scrollToTop() + } else if (index === listsIndex) { + listsSectionRef.current?.scrollToTop() + } + }, + [postsIndex, repliesIndex, mediaIndex, likesIndex, feedsIndex, listsIndex], + ) - // rendering - // = + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + screen('Profile') + return listenSoftReset(() => { + scrollSectionToTop(currentPage) + }) + }, [setMinimalShellMode, screen, currentPage, scrollSectionToTop]), + ) - const renderHeader = React.useCallback(() => { - if (!uiState) { - return <View /> + useFocusEffect( + React.useCallback(() => { + setDrawerSwipeDisabled(currentPage > 0) + return () => { + setDrawerSwipeDisabled(false) } - return ( - <ProfileHeader - view={uiState.profile} - onRefreshAll={onRefresh} - hideBackButton={route.params.hideBackButton} - /> - ) - }, [uiState, onRefresh, route.params.hideBackButton]) + }, [setDrawerSwipeDisabled, currentPage]), + ) - const Footer = React.useMemo(() => { - return uiState.showLoadingMoreFooter ? LoadingMoreFooter : undefined - }, [uiState.showLoadingMoreFooter]) - const renderItem = React.useCallback( - (item: any) => { - // if section is lists - if (uiState.selectedView === Sections.Lists) { - if (item === ProfileUiModel.LOADING_ITEM) { - return <ProfileCardFeedLoadingPlaceholder /> - } else if (item._reactKey === '__error__') { - return ( - <View style={s.p5}> - <ErrorMessage - message={item.error} - onPressTryAgain={onPressTryAgain} - /> - </View> - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - <EmptyState - testID="listsEmpty" - icon="list-ul" - message="No lists yet!" - style={styles.emptyState} + // events + // = + + const onPressCompose = React.useCallback(() => { + track('ProfileScreen:PressCompose') + const mention = + profile.handle === currentAccount?.handle || + profile.handle === 'handle.invalid' + ? undefined + : profile.handle + openComposer({mention}) + }, [openComposer, currentAccount, track, profile]) + + const onPageSelected = React.useCallback( + (i: number) => { + setCurrentPage(i) + }, + [setCurrentPage], + ) + + const onCurrentPageSelected = React.useCallback( + (index: number) => { + scrollSectionToTop(index) + }, + [scrollSectionToTop], + ) + + // rendering + // = + + const renderHeader = React.useCallback(() => { + return ( + <ProfileHeader + profile={profile} + moderation={moderation} + hideBackButton={hideBackButton} + /> + ) + }, [profile, moderation, hideBackButton]) + + return ( + <ScreenHider + testID="profileView" + style={styles.container} + screenDescription="profile" + moderation={moderation.account}> + <PagerWithHeader + testID="profilePager" + isHeaderReady={true} + items={sectionTitles} + onPageSelected={onPageSelected} + onCurrentPageSelected={onCurrentPageSelected} + renderHeader={renderHeader}> + {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => ( + <FeedSection + ref={postsSectionRef} + feed={`author|${profile.did}|posts_no_replies`} + onScroll={onScroll} + headerHeight={headerHeight} + isFocused={isFocused} + isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + /> + )} + {showRepliesTab + ? ({ + onScroll, + headerHeight, + isFocused, + isScrolledDown, + scrollElRef, + }) => ( + <FeedSection + ref={repliesSectionRef} + feed={`author|${profile.did}|posts_with_replies`} + onScroll={onScroll} + headerHeight={headerHeight} + isFocused={isFocused} + isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } /> ) - } else { - return <ListCard testID={`list-${item.name}`} list={item} /> - } - // if section is custom algorithms - } else if (uiState.selectedView === Sections.CustomAlgorithms) { - if (item === ProfileUiModel.LOADING_ITEM) { - return <ProfileCardFeedLoadingPlaceholder /> - } else if (item._reactKey === '__error__') { - return ( - <View style={s.p5}> - <ErrorMessage - message={item.error} - onPressTryAgain={onPressTryAgain} - /> - </View> - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - <EmptyState - testID="customAlgorithmsEmpty" - icon="list-ul" - message="No custom algorithms yet!" - style={styles.emptyState} + : null} + {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => ( + <FeedSection + ref={mediaSectionRef} + feed={`author|${profile.did}|posts_with_media`} + onScroll={onScroll} + headerHeight={headerHeight} + isFocused={isFocused} + isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + /> + )} + {showLikesTab + ? ({ + onScroll, + headerHeight, + isFocused, + isScrolledDown, + scrollElRef, + }) => ( + <FeedSection + ref={likesSectionRef} + feed={`likes|${profile.did}`} + onScroll={onScroll} + headerHeight={headerHeight} + isFocused={isFocused} + isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } /> ) - } else if (item instanceof FeedSourceModel) { - return ( - <FeedSourceCard - item={item} - showSaveBtn - showLikes - showDescription + : null} + {showFeedsTab + ? ({onScroll, headerHeight, isFocused, scrollElRef}) => ( + <ProfileFeedgens + ref={feedsSectionRef} + did={profile.did} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + onScroll={onScroll} + scrollEventThrottle={1} + headerOffset={headerHeight} + enabled={isFocused} /> ) - } - // if section is posts or posts & replies - } else { - if (item === ProfileUiModel.END_ITEM) { - return <Text style={styles.endItem}>- end of feed -</Text> - } else if (item === ProfileUiModel.LOADING_ITEM) { - return <PostFeedLoadingPlaceholder /> - } else if (item._reactKey === '__error__') { - if (uiState.feed.isBlocking) { - return ( - <EmptyState - icon="ban" - message="Posts hidden" - style={styles.emptyState} - /> - ) - } - if (uiState.feed.isBlockedBy) { - return ( - <EmptyState - icon="ban" - message="Posts hidden" - style={styles.emptyState} - /> - ) - } - return ( - <View style={s.p5}> - <ErrorMessage - message={item.error} - onPressTryAgain={onPressTryAgain} - /> - </View> - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - <EmptyState - icon={['far', 'message']} - message="No posts yet!" - style={styles.emptyState} + : null} + {showListsTab + ? ({onScroll, headerHeight, isFocused, scrollElRef}) => ( + <ProfileLists + ref={listsSectionRef} + did={profile.did} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + onScroll={onScroll} + scrollEventThrottle={1} + headerOffset={headerHeight} + enabled={isFocused} /> ) - } else if (item instanceof PostsFeedSliceModel) { - return ( - <FeedSlice slice={item} ignoreFilterFor={uiState.profile.did} /> - ) - } - } - return <View /> - }, - [ - onPressTryAgain, - uiState.selectedView, - uiState.profile.did, - uiState.feed.isBlocking, - uiState.feed.isBlockedBy, - ], - ) - - return ( - <ScreenHider - testID="profileView" - style={styles.container} - screenDescription="profile" - moderation={uiState.profile.moderation.account}> - {uiState.profile.hasError ? ( - <ErrorScreen - testID="profileErrorScreen" - title="Failed to load profile" - message={uiState.profile.error} - onPressTryAgain={onPressTryAgain} - /> - ) : uiState.profile.hasLoaded ? ( - <ViewSelector - ref={viewSelectorRef} - swipeEnabled={false} - sections={uiState.selectorItems} - items={uiState.uiItems} - renderHeader={renderHeader} - renderItem={renderItem} - ListFooterComponent={Footer} - refreshing={uiState.isRefreshing || false} - onSelectView={onSelectView} - onRefresh={onRefresh} - onEndReached={onEndReached} - /> - ) : ( - <CenteredView>{renderHeader()}</CenteredView> - )} + : null} + </PagerWithHeader> + {hasSession && ( <FAB testID="composeFAB" onPress={onPressCompose} icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} accessibilityRole="button" - accessibilityLabel="New post" + accessibilityLabel={_(msg`New post`)} accessibilityHint="" /> - </ScreenHider> - ) - }), -) - -function LoadingMoreFooter() { - return ( - <View style={styles.loadingMoreFooter}> - <ActivityIndicator /> - </View> + )} + </ScreenHider> ) } +interface FeedSectionProps { + feed: FeedDescriptor + onScroll: OnScrollHandler + headerHeight: number + isFocused: boolean + isScrolledDown: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | null> +} +const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( + function FeedSectionImpl( + {feed, onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}, + ref, + ) { + const queryClient = useQueryClient() + const [hasNew, setHasNew] = React.useState(false) + + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerHeight}) + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderPostsEmpty = React.useCallback(() => { + return <EmptyState icon="feed" message="This feed is empty!" /> + }, []) + + return ( + <View> + <Feed + testID="postsFeed" + enabled={isFocused} + feed={feed} + pollInterval={30e3} + scrollElRef={scrollElRef} + onHasNew={setHasNew} + onScroll={onScroll} + scrollEventThrottle={1} + renderEmptyState={renderPostsEmpty} + headerOffset={headerHeight} + /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onScrollToTop} + label="Load new posts" + showIndicator={hasNew} + /> + )} + </View> + ) + }, +) + const styles = StyleSheet.create({ container: { flexDirection: 'column', diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx index a4d146d66..3a0bdcc0f 100644 --- a/src/view/screens/ProfileFeed.tsx +++ b/src/view/screens/ProfileFeed.tsx @@ -1,25 +1,21 @@ import React, {useMemo, useCallback} from 'react' import { - FlatList, - NativeScrollEvent, + Dimensions, StyleSheet, View, ActivityIndicator, + FlatList, } from 'react-native' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {useNavigation} from '@react-navigation/native' -import {useAnimatedScrollHandler} from 'react-native-reanimated' +import {useQueryClient} from '@tanstack/react-query' import {usePalette} from 'lib/hooks/usePalette' import {HeartIcon, HeartIconSolid} from 'lib/icons' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {CommonNavigatorParams} from 'lib/routes/types' import {makeRecordUri} from 'lib/strings/url-helpers' import {colors, s} from 'lib/styles' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' -import {FeedSourceModel} from 'state/models/content/feed-source' -import {PostsFeedModel} from 'state/models/feeds/posts' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {FeedDescriptor} from '#/state/queries/post-feed' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' import {Feed} from 'view/com/posts/Feed' @@ -32,13 +28,13 @@ import {FAB} from 'view/com/util/fab/FAB' import {EmptyState} from 'view/com/util/EmptyState' import * as Toast from 'view/com/util/Toast' import {useSetTitle} from 'lib/hooks/useSetTitle' -import {useCustomFeed} from 'lib/hooks/useCustomFeed' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {shareUrl} from 'lib/sharing' import {toShareUrl} from 'lib/strings/url-helpers' import {Haptics} from 'lib/haptics' import {useAnalytics} from 'lib/analytics/analytics' import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' -import {resolveName} from 'lib/api' import {makeCustomFeedLink} from 'lib/routes/links' import {pluralize} from 'lib/strings/helpers' import {CenteredView, ScrollView} from 'view/com/util/Views' @@ -47,6 +43,28 @@ import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' import {ComposeIcon2} from 'lib/icons' import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' +import { + useFeedSourceInfoQuery, + FeedSourceFeedInfo, + useIsFeedPublicQuery, +} from '#/state/queries/feed' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import { + UsePreferencesQueryResponse, + usePreferencesQuery, + useSaveFeedMutation, + useRemoveFeedMutation, + usePinFeedMutation, + useUnpinFeedMutation, +} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' +import {useComposerControls} from '#/state/shell/composer' +import {truncateAndInvalidate} from '#/state/queries/util' const SECTION_TITLES = ['Posts', 'About'] @@ -55,315 +73,372 @@ interface SectionRef { } type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'> -export const ProfileFeedScreen = withAuthRequired( - observer(function ProfileFeedScreenImpl(props: Props) { - const pal = usePalette('default') - const store = useStores() - const navigation = useNavigation<NavigationProp>() +export function ProfileFeedScreen(props: Props) { + const {rkey, name: handleOrDid} = props.route.params - const {name: handleOrDid} = props.route.params + const pal = usePalette('default') + const {_} = useLingui() + const navigation = useNavigation<NavigationProp>() - const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>() - const [error, setError] = React.useState<string | undefined>() + const uri = useMemo( + () => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey), + [rkey, handleOrDid], + ) + const {error, data: resolvedUri} = useResolveUriQuery(uri) - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - - React.useEffect(() => { - /* - * We must resolve the DID of the feed owner before we can fetch the feed. - */ - async function fetchDid() { - try { - const did = await resolveName(store, handleOrDid) - setFeedOwnerDid(did) - } catch (e) { - setError( - `We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`, - ) - } - } + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + if (error) { + return ( + <CenteredView> + <View style={[pal.view, pal.border, styles.notFoundContainer]}> + <Text type="title-lg" style={[pal.text, s.mb10]}> + <Trans>Could not load feed</Trans> + </Text> + <Text type="md" style={[pal.text, s.mb20]}> + {error.toString()} + </Text> - fetchDid() - }, [store, handleOrDid, setFeedOwnerDid]) - - if (error) { - return ( - <CenteredView> - <View style={[pal.view, pal.border, styles.notFoundContainer]}> - <Text type="title-lg" style={[pal.text, s.mb10]}> - Could not load feed - </Text> - <Text type="md" style={[pal.text, s.mb20]}> - {error} - </Text> - - <View style={{flexDirection: 'row'}}> - <Button - type="default" - accessibilityLabel="Go Back" - accessibilityHint="Return to previous page" - onPress={onPressBack} - style={{flexShrink: 1}}> - <Text type="button" style={pal.text}> - Go Back - </Text> - </Button> - </View> + <View style={{flexDirection: 'row'}}> + <Button + type="default" + accessibilityLabel={_(msg`Go Back`)} + accessibilityHint="Return to previous page" + onPress={onPressBack} + style={{flexShrink: 1}}> + <Text type="button" style={pal.text}> + <Trans>Go Back</Trans> + </Text> + </Button> </View> - </CenteredView> - ) - } + </View> + </CenteredView> + ) + } - return feedOwnerDid ? ( - <ProfileFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} /> - ) : ( + return resolvedUri ? ( + <ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} /> + ) : ( + <CenteredView> + <View style={s.p20}> + <ActivityIndicator size="large" /> + </View> + </CenteredView> + ) +} + +function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { + const {data: preferences} = usePreferencesQuery() + const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) + const {isLoading: isPublicStatusLoading, data: isPublic} = + useIsFeedPublicQuery({uri: feedUri}) + + if (!preferences || !info || isPublicStatusLoading) { + return ( <CenteredView> <View style={s.p20}> <ActivityIndicator size="large" /> </View> </CenteredView> ) - }), -) + } -export const ProfileFeedScreenInner = observer( - function ProfileFeedScreenInnerImpl({ - route, - feedOwnerDid, - }: Props & {feedOwnerDid: string}) { - const pal = usePalette('default') - const store = useStores() - const {track} = useAnalytics() - const feedSectionRef = React.useRef<SectionRef>(null) - const {rkey, name: handleOrDid} = route.params - const uri = useMemo( - () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey), - [rkey, feedOwnerDid], - ) - const feedInfo = useCustomFeed(uri) - const feed: PostsFeedModel = useMemo(() => { - const model = new PostsFeedModel(store, 'custom', { - feed: uri, - }) - model.setup() - return model - }, [store, uri]) - const isPinned = store.preferences.isPinnedFeed(uri) - useSetTitle(feedInfo?.displayName) - - // events - // = - - const onToggleSaved = React.useCallback(async () => { - try { - Haptics.default() - if (feedInfo?.isSaved) { - await feedInfo?.unsave() - } else { - await feedInfo?.save() - } - } catch (err) { - Toast.show( - 'There was an an issue updating your feeds, please check your internet connection and try again.', - ) - logger.error('Failed up update feeds', {error: err}) - } - }, [feedInfo]) + return ( + <ProfileFeedScreenInner + preferences={preferences} + feedInfo={info as FeedSourceFeedInfo} + isPublic={Boolean(isPublic)} + /> + ) +} - const onToggleLiked = React.useCallback(async () => { +export function ProfileFeedScreenInner({ + preferences, + feedInfo, + isPublic, +}: { + preferences: UsePreferencesQueryResponse + feedInfo: FeedSourceFeedInfo + isPublic: boolean +}) { + const {_} = useLingui() + const pal = usePalette('default') + const {hasSession, currentAccount} = useSession() + const {openModal} = useModalControls() + const {openComposer} = useComposerControls() + const {track} = useAnalytics() + const feedSectionRef = React.useRef<SectionRef>(null) + + const { + mutateAsync: saveFeed, + variables: savedFeed, + reset: resetSaveFeed, + isPending: isSavePending, + } = useSaveFeedMutation() + const { + mutateAsync: removeFeed, + variables: removedFeed, + reset: resetRemoveFeed, + isPending: isRemovePending, + } = useRemoveFeedMutation() + const { + mutateAsync: pinFeed, + variables: pinnedFeed, + reset: resetPinFeed, + isPending: isPinPending, + } = usePinFeedMutation() + const { + mutateAsync: unpinFeed, + variables: unpinnedFeed, + reset: resetUnpinFeed, + isPending: isUnpinPending, + } = useUnpinFeedMutation() + + const isSaved = + !removedFeed && + (!!savedFeed || preferences.feeds.saved.includes(feedInfo.uri)) + const isPinned = + !unpinnedFeed && + (!!pinnedFeed || preferences.feeds.pinned.includes(feedInfo.uri)) + + useSetTitle(feedInfo?.displayName) + + const onToggleSaved = React.useCallback(async () => { + try { Haptics.default() - try { - if (feedInfo?.isLiked) { - await feedInfo?.unlike() - } else { - await feedInfo?.like() - } - } catch (err) { - Toast.show( - 'There was an an issue contacting the server, please check your internet connection and try again.', - ) - logger.error('Failed up toggle like', {error: err}) + + if (isSaved) { + await removeFeed({uri: feedInfo.uri}) + resetRemoveFeed() + } else { + await saveFeed({uri: feedInfo.uri}) + resetSaveFeed() } - }, [feedInfo]) + } catch (err) { + Toast.show( + 'There was an an issue updating your feeds, please check your internet connection and try again.', + ) + logger.error('Failed up update feeds', {error: err}) + } + }, [feedInfo, isSaved, saveFeed, removeFeed, resetSaveFeed, resetRemoveFeed]) - const onTogglePinned = React.useCallback(async () => { + const onTogglePinned = React.useCallback(async () => { + try { Haptics.default() - if (feedInfo) { - feedInfo.togglePin().catch(e => { - Toast.show('There was an issue contacting the server') - logger.error('Failed to toggle pinned feed', {error: e}) - }) - } - }, [feedInfo]) - - const onPressShare = React.useCallback(() => { - const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`) - shareUrl(url) - track('CustomFeed:Share') - }, [handleOrDid, rkey, track]) - - const onPressReport = React.useCallback(() => { - if (!feedInfo) return - store.shell.openModal({ - name: 'report', - uri: feedInfo.uri, - cid: feedInfo.cid, - }) - }, [store, feedInfo]) - - const onCurrentPageSelected = React.useCallback( - (index: number) => { - if (index === 0) { - feedSectionRef.current?.scrollToTop() - } - }, - [feedSectionRef], - ) - // render - // = + if (isPinned) { + await unpinFeed({uri: feedInfo.uri}) + resetUnpinFeed() + } else { + await pinFeed({uri: feedInfo.uri}) + resetPinFeed() + } + } catch (e) { + Toast.show('There was an issue contacting the server') + logger.error('Failed to toggle pinned feed', {error: e}) + } + }, [isPinned, feedInfo, pinFeed, unpinFeed, resetPinFeed, resetUnpinFeed]) + + const onPressShare = React.useCallback(() => { + const url = toShareUrl(feedInfo.route.href) + shareUrl(url) + track('CustomFeed:Share') + }, [feedInfo, track]) + + const onPressReport = React.useCallback(() => { + if (!feedInfo) return + openModal({ + name: 'report', + uri: feedInfo.uri, + cid: feedInfo.cid, + }) + }, [openModal, feedInfo]) + + const onCurrentPageSelected = React.useCallback( + (index: number) => { + if (index === 0) { + feedSectionRef.current?.scrollToTop() + } + }, + [feedSectionRef], + ) - const dropdownItems: DropdownItem[] = React.useMemo(() => { - return [ - { - testID: 'feedHeaderDropdownToggleSavedBtn', - label: feedInfo?.isSaved ? 'Remove from my feeds' : 'Add to my feeds', - onPress: onToggleSaved, - icon: feedInfo?.isSaved - ? { - ios: { - name: 'trash', - }, - android: 'ic_delete', - web: ['far', 'trash-can'], - } - : { - ios: { - name: 'plus', - }, - android: '', - web: 'plus', + // render + // = + + const dropdownItems: DropdownItem[] = React.useMemo(() => { + return [ + hasSession && { + testID: 'feedHeaderDropdownToggleSavedBtn', + label: isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`), + onPress: isSavePending || isRemovePending ? undefined : onToggleSaved, + icon: isSaved + ? { + ios: { + name: 'trash', }, - }, - { - testID: 'feedHeaderDropdownReportBtn', - label: 'Report feed', - onPress: onPressReport, - icon: { - ios: { - name: 'exclamationmark.triangle', + android: 'ic_delete', + web: ['far', 'trash-can'], + } + : { + ios: { + name: 'plus', + }, + android: '', + web: 'plus', }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', + }, + hasSession && { + testID: 'feedHeaderDropdownReportBtn', + label: _(msg`Report feed`), + onPress: onPressReport, + icon: { + ios: { + name: 'exclamationmark.triangle', }, + android: 'ic_menu_report_image', + web: 'circle-exclamation', }, - { - testID: 'feedHeaderDropdownShareBtn', - label: 'Share link', - onPress: onPressShare, - icon: { - ios: { - name: 'square.and.arrow.up', - }, - android: 'ic_menu_share', - web: 'share', + }, + { + testID: 'feedHeaderDropdownShareBtn', + label: _(msg`Share feed`), + onPress: onPressShare, + icon: { + ios: { + name: 'square.and.arrow.up', }, + android: 'ic_menu_share', + web: 'share', }, - ] as DropdownItem[] - }, [feedInfo, onToggleSaved, onPressReport, onPressShare]) - - const renderHeader = useCallback(() => { - return ( - <ProfileSubpageHeader - isLoading={!feedInfo?.hasLoaded} - href={makeCustomFeedLink(feedOwnerDid, rkey)} - title={feedInfo?.displayName} - avatar={feedInfo?.avatar} - isOwner={feedInfo?.isOwner} - creator={ - feedInfo - ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle} - : undefined - } - avatarType="algo"> - {feedInfo && ( - <> - <Button - type="default" - label={feedInfo?.isSaved ? 'Unsave' : 'Save'} - onPress={onToggleSaved} - style={styles.btn} - /> - <Button - type={isPinned ? 'default' : 'inverted'} - label={isPinned ? 'Unpin' : 'Pin to home'} - onPress={onTogglePinned} - style={styles.btn} - /> - </> - )} - <NativeDropdown - testID="headerDropdownBtn" - items={dropdownItems} - accessibilityLabel="More options" - accessibilityHint=""> - <View style={[pal.viewLight, styles.btn]}> - <FontAwesomeIcon - icon="ellipsis" - size={20} - color={pal.colors.text} - /> - </View> - </NativeDropdown> - </ProfileSubpageHeader> - ) - }, [ - pal, - feedOwnerDid, - rkey, - feedInfo, - isPinned, - onTogglePinned, - onToggleSaved, - dropdownItems, - ]) - + }, + ].filter(Boolean) as DropdownItem[] + }, [ + hasSession, + onToggleSaved, + onPressReport, + onPressShare, + isSaved, + isSavePending, + isRemovePending, + _, + ]) + + const renderHeader = useCallback(() => { return ( - <View style={s.hContentRegion}> - <PagerWithHeader - items={SECTION_TITLES} - isHeaderReady={feedInfo?.hasLoaded ?? false} - renderHeader={renderHeader} - onCurrentPageSelected={onCurrentPageSelected}> - {({onScroll, headerHeight, isScrolledDown}) => ( + <ProfileSubpageHeader + isLoading={false} + href={feedInfo.route.href} + title={feedInfo?.displayName} + avatar={feedInfo?.avatar} + isOwner={feedInfo.creatorDid === currentAccount?.did} + creator={ + feedInfo + ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle} + : undefined + } + avatarType="algo"> + {feedInfo && hasSession && ( + <> + <Button + disabled={isSavePending || isRemovePending} + type="default" + label={isSaved ? 'Unsave' : 'Save'} + onPress={onToggleSaved} + style={styles.btn} + /> + <Button + testID={isPinned ? 'unpinBtn' : 'pinBtn'} + disabled={isPinPending || isUnpinPending} + type={isPinned ? 'default' : 'inverted'} + label={isPinned ? 'Unpin' : 'Pin to home'} + onPress={onTogglePinned} + style={styles.btn} + /> + </> + )} + <NativeDropdown + testID="headerDropdownBtn" + items={dropdownItems} + accessibilityLabel={_(msg`More options`)} + accessibilityHint=""> + <View style={[pal.viewLight, styles.btn]}> + <FontAwesomeIcon + icon="ellipsis" + size={20} + color={pal.colors.text} + /> + </View> + </NativeDropdown> + </ProfileSubpageHeader> + ) + }, [ + _, + hasSession, + pal, + feedInfo, + isPinned, + onTogglePinned, + onToggleSaved, + dropdownItems, + currentAccount?.did, + isPinPending, + isRemovePending, + isSavePending, + isSaved, + isUnpinPending, + ]) + + return ( + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES} + isHeaderReady={true} + renderHeader={renderHeader} + onCurrentPageSelected={onCurrentPageSelected}> + {({onScroll, headerHeight, isScrolledDown, scrollElRef, isFocused}) => + isPublic ? ( <FeedSection ref={feedSectionRef} - feed={feed} + feed={`feedgen|${feedInfo.uri}`} onScroll={onScroll} headerHeight={headerHeight} isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + isFocused={isFocused} /> - )} - {({onScroll, headerHeight}) => ( - <AboutSection - feedOwnerDid={feedOwnerDid} - feedRkey={rkey} - feedInfo={feedInfo} - headerHeight={headerHeight} - onToggleLiked={onToggleLiked} - onScroll={onScroll} - /> - )} - </PagerWithHeader> + ) : ( + <CenteredView sideBorders style={[{paddingTop: headerHeight}]}> + <NonPublicFeedMessage /> + </CenteredView> + ) + } + {({onScroll, headerHeight, scrollElRef}) => ( + <AboutSection + feedOwnerDid={feedInfo.creatorDid} + feedRkey={feedInfo.route.params.rkey} + feedInfo={feedInfo} + headerHeight={headerHeight} + onScroll={onScroll} + scrollElRef={ + scrollElRef as React.MutableRefObject<ScrollView | null> + } + isOwner={feedInfo.creatorDid === currentAccount?.did} + /> + )} + </PagerWithHeader> + {hasSession && ( <FAB testID="composeFAB" - onPress={() => store.shell.openComposer({})} + onPress={() => openComposer({})} icon={ <ComposeIcon2 strokeWidth={1.5} @@ -372,32 +447,67 @@ export const ProfileFeedScreenInner = observer( /> } accessibilityRole="button" - accessibilityLabel="New post" + accessibilityLabel={_(msg`New post`)} accessibilityHint="" /> + )} + </View> + ) +} + +function NonPublicFeedMessage() { + const pal = usePalette('default') + + return ( + <View + style={[ + pal.border, + { + padding: 18, + borderTopWidth: 1, + minHeight: Dimensions.get('window').height * 1.5, + }, + ]}> + <View + style={[ + pal.viewLight, + { + padding: 12, + borderRadius: 8, + }, + ]}> + <Text style={[pal.text]}> + <Trans> + Looks like this feed is only available to users with a Bluesky + account. Please sign up or sign in to view this feed! + </Trans> + </Text> </View> - ) - }, -) + </View> + ) +} interface FeedSectionProps { - feed: PostsFeedModel - onScroll: (e: NativeScrollEvent) => void + feed: FeedDescriptor + onScroll: OnScrollHandler headerHeight: number isScrolledDown: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | null> + isFocused: boolean } const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( function FeedSectionImpl( - {feed, onScroll, headerHeight, isScrolledDown}, + {feed, onScroll, headerHeight, isScrolledDown, scrollElRef, isFocused}, ref, ) { - const hasNew = feed.hasNewLatest && !feed.isRefreshing - const scrollElRef = React.useRef<FlatList>(null) + const [hasNew, setHasNew] = React.useState(false) + const queryClient = useQueryClient() const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) - feed.refresh() - }, [feed, scrollElRef, headerHeight]) + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) React.useImperativeHandle(ref, () => ({ scrollToTop: onScrollToTop, @@ -407,13 +517,15 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( return <EmptyState icon="feed" message="This feed is empty!" /> }, []) - const scrollHandler = useAnimatedScrollHandler({onScroll}) return ( <View> <Feed + enabled={isFocused} feed={feed} + pollInterval={30e3} scrollElRef={scrollElRef} - onScroll={scrollHandler} + onHasNew={setHasNew} + onScroll={onScroll} scrollEventThrottle={5} renderEmptyState={renderPostsEmpty} headerOffset={headerHeight} @@ -430,32 +542,64 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( }, ) -const AboutSection = observer(function AboutPageImpl({ +function AboutSection({ feedOwnerDid, feedRkey, feedInfo, headerHeight, - onToggleLiked, onScroll, + scrollElRef, + isOwner, }: { feedOwnerDid: string feedRkey: string - feedInfo: FeedSourceModel | undefined + feedInfo: FeedSourceFeedInfo headerHeight: number - onToggleLiked: () => void - onScroll: (e: NativeScrollEvent) => void + onScroll: OnScrollHandler + scrollElRef: React.MutableRefObject<ScrollView | null> + isOwner: boolean }) { const pal = usePalette('default') - const scrollHandler = useAnimatedScrollHandler({onScroll}) + const {_} = useLingui() + const scrollHandler = useAnimatedScrollHandler(onScroll) + const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri) + const {hasSession} = useSession() - if (!feedInfo) { - return <View /> - } + const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation() + const {mutateAsync: unlikeFeed, isPending: isUnlikePending} = + useUnlikeMutation() + + const isLiked = !!likeUri + const likeCount = + isLiked && likeUri ? (feedInfo.likeCount || 0) + 1 : feedInfo.likeCount + + const onToggleLiked = React.useCallback(async () => { + try { + Haptics.default() + + if (isLiked && likeUri) { + await unlikeFeed({uri: likeUri}) + setLikeUri('') + } else { + const res = await likeFeed({uri: feedInfo.uri, cid: feedInfo.cid}) + setLikeUri(res.uri) + } + } catch (err) { + Toast.show( + 'There was an an issue contacting the server, please check your internet connection and try again.', + ) + logger.error('Failed up toggle like', {error: err}) + } + }, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed]) return ( <ScrollView + ref={scrollElRef} scrollEventThrottle={1} - contentContainerStyle={{paddingTop: headerHeight}} + contentContainerStyle={{ + paddingTop: headerHeight, + minHeight: Dimensions.get('window').height * 1.5, + }} onScroll={scrollHandler}> <View style={[ @@ -467,46 +611,44 @@ const AboutSection = observer(function AboutPageImpl({ }, pal.border, ]}> - {feedInfo.descriptionRT ? ( + {feedInfo.description ? ( <RichText testID="listDescription" type="lg" style={pal.text} - richText={feedInfo.descriptionRT} + richText={feedInfo.description} /> ) : ( <Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}> - No description + <Trans>No description</Trans> </Text> )} <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> <Button type="default" testID="toggleLikeBtn" - accessibilityLabel="Like this feed" + accessibilityLabel={_(msg`Like this feed`)} accessibilityHint="" + disabled={!hasSession || isLikePending || isUnlikePending} onPress={onToggleLiked} style={{paddingHorizontal: 10}}> - {feedInfo?.isLiked ? ( + {isLiked ? ( <HeartIconSolid size={19} style={styles.liked} /> ) : ( <HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> )} </Button> - {typeof feedInfo.likeCount === 'number' && ( + {typeof likeCount === 'number' && ( <TextLink href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')} - text={`Liked by ${feedInfo.likeCount} ${pluralize( - feedInfo.likeCount, - 'user', - )}`} + text={`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`} style={[pal.textLight, s.semiBold]} /> )} </View> <Text type="md" style={[pal.textLight]} numberOfLines={1}> Created by{' '} - {feedInfo.isOwner ? ( + {isOwner ? ( 'you' ) : ( <TextLink @@ -522,7 +664,7 @@ const AboutSection = observer(function AboutPageImpl({ </View> </ScrollView> ) -}) +} const styles = StyleSheet.create({ btn: { diff --git a/src/view/screens/ProfileFeedLikedBy.tsx b/src/view/screens/ProfileFeedLikedBy.tsx index 4972116f3..0460670e1 100644 --- a/src/view/screens/ProfileFeedLikedBy.tsx +++ b/src/view/screens/ProfileFeedLikedBy.tsx @@ -2,17 +2,19 @@ import React from 'react' import {View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' import {makeRecordUri} from 'lib/strings/url-helpers' import {useSetMinimalShellMode} from '#/state/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeedLikedBy'> -export const ProfileFeedLikedByScreen = withAuthRequired(({route}: Props) => { +export const ProfileFeedLikedByScreen = ({route}: Props) => { const setMinimalShellMode = useSetMinimalShellMode() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey) + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -22,8 +24,8 @@ export const ProfileFeedLikedByScreen = withAuthRequired(({route}: Props) => { return ( <View> - <ViewHeader title="Liked by" /> + <ViewHeader title={_(msg`Liked by`)} /> <PostLikedByComponent uri={uri} /> </View> ) -}) +} diff --git a/src/view/screens/ProfileFollowers.tsx b/src/view/screens/ProfileFollowers.tsx index 49f55bf46..2cad08cb5 100644 --- a/src/view/screens/ProfileFollowers.tsx +++ b/src/view/screens/ProfileFollowers.tsx @@ -2,15 +2,17 @@ import React from 'react' import {View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers' import {useSetMinimalShellMode} from '#/state/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollowers'> -export const ProfileFollowersScreen = withAuthRequired(({route}: Props) => { +export const ProfileFollowersScreen = ({route}: Props) => { const {name} = route.params const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -20,8 +22,8 @@ export const ProfileFollowersScreen = withAuthRequired(({route}: Props) => { return ( <View> - <ViewHeader title="Followers" /> + <ViewHeader title={_(msg`Followers`)} /> <ProfileFollowersComponent name={name} /> </View> ) -}) +} diff --git a/src/view/screens/ProfileFollows.tsx b/src/view/screens/ProfileFollows.tsx index 4f0ff7d67..80502b98b 100644 --- a/src/view/screens/ProfileFollows.tsx +++ b/src/view/screens/ProfileFollows.tsx @@ -2,15 +2,17 @@ import React from 'react' import {View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows' import {useSetMinimalShellMode} from '#/state/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollows'> -export const ProfileFollowsScreen = withAuthRequired(({route}: Props) => { +export const ProfileFollowsScreen = ({route}: Props) => { const {name} = route.params const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -20,8 +22,8 @@ export const ProfileFollowsScreen = withAuthRequired(({route}: Props) => { return ( <View> - <ViewHeader title="Following" /> + <ViewHeader title={_(msg`Following`)} /> <ProfileFollowsComponent name={name} /> </View> ) -}) +} diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index b84732d53..421611764 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -2,7 +2,6 @@ import React, {useCallback, useMemo} from 'react' import { ActivityIndicator, FlatList, - NativeScrollEvent, Pressable, StyleSheet, View, @@ -11,10 +10,8 @@ import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useAnimatedScrollHandler} from 'react-native-reanimated' -import {observer} from 'mobx-react-lite' -import {RichText as RichTextAPI} from '@atproto/api' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {AppBskyGraphDefs, AtUri, RichText as RichTextAPI} from '@atproto/api' +import {useQueryClient} from '@tanstack/react-query' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' import {Feed} from 'view/com/posts/Feed' @@ -29,23 +26,36 @@ import * as Toast from 'view/com/util/Toast' import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' import {FAB} from 'view/com/util/fab/FAB' import {Haptics} from 'lib/haptics' -import {ListModel} from 'state/models/content/list' -import {PostsFeedModel} from 'state/models/feeds/posts' -import {useStores} from 'state/index' +import {FeedDescriptor} from '#/state/queries/post-feed' import {usePalette} from 'lib/hooks/usePalette' import {useSetTitle} from 'lib/hooks/useSetTitle' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {NavigationProp} from 'lib/routes/types' import {toShareUrl} from 'lib/strings/url-helpers' import {shareUrl} from 'lib/sharing' -import {resolveName} from 'lib/api' import {s} from 'lib/styles' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink, makeListLink} from 'lib/routes/links' import {ComposeIcon2} from 'lib/icons' -import {ListItems} from 'view/com/lists/ListItems' -import {logger} from '#/logger' +import {ListMembers} from '#/view/com/lists/ListMembers' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import { + useListQuery, + useListMuteMutation, + useListBlockMutation, + useListDeleteMutation, +} from '#/state/queries/list' +import {cleanError} from '#/lib/strings/errors' +import {useSession} from '#/state/session' +import {useComposerControls} from '#/state/shell/composer' +import {isWeb} from '#/platform/detection' +import {truncateAndInvalidate} from '#/state/queries/util' const SECTION_TITLES_CURATE = ['Posts', 'About'] const SECTION_TITLES_MOD = ['About'] @@ -55,240 +65,220 @@ interface SectionRef { } type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'> -export const ProfileListScreen = withAuthRequired( - observer(function ProfileListScreenImpl(props: Props) { - const store = useStores() - const {name: handleOrDid} = props.route.params - const [listOwnerDid, setListOwnerDid] = React.useState<string | undefined>() - const [error, setError] = React.useState<string | undefined>() - - React.useEffect(() => { - /* - * We must resolve the DID of the list owner before we can fetch the list. - */ - async function fetchDid() { - try { - const did = await resolveName(store, handleOrDid) - setListOwnerDid(did) - } catch (e) { - setError( - `We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`, - ) - } - } - - fetchDid() - }, [store, handleOrDid, setListOwnerDid]) - - if (error) { - return ( - <CenteredView> - <ErrorScreen error={error} /> - </CenteredView> - ) - } +export function ProfileListScreen(props: Props) { + const {name: handleOrDid, rkey} = props.route.params + const {data: resolvedUri, error: resolveError} = useResolveUriQuery( + AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(), + ) + const {data: list, error: listError} = useListQuery(resolvedUri?.uri) - return listOwnerDid ? ( - <ProfileListScreenInner {...props} listOwnerDid={listOwnerDid} /> - ) : ( + if (resolveError) { + return ( <CenteredView> - <View style={s.p20}> - <ActivityIndicator size="large" /> - </View> + <ErrorScreen + error={`We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`} + /> </CenteredView> ) - }), -) - -export const ProfileListScreenInner = observer( - function ProfileListScreenInnerImpl({ - route, - listOwnerDid, - }: Props & {listOwnerDid: string}) { - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {rkey} = route.params - const feedSectionRef = React.useRef<SectionRef>(null) - const aboutSectionRef = React.useRef<SectionRef>(null) - - const list: ListModel = useMemo(() => { - const model = new ListModel( - store, - `at://${listOwnerDid}/app.bsky.graph.list/${rkey}`, - ) - return model - }, [store, listOwnerDid, rkey]) - const feed = useMemo( - () => new PostsFeedModel(store, 'list', {list: list.uri}), - [store, list], - ) - useSetTitle(list.data?.name) - - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - list.loadMore(true).then(() => { - if (list.isCuratelist) { - feed.setup() - } - }) - }, [setMinimalShellMode, list, feed]), + } + if (listError) { + return ( + <CenteredView> + <ErrorScreen error={cleanError(listError)} /> + </CenteredView> ) + } + + return resolvedUri && list ? ( + <ProfileListScreenLoaded {...props} uri={resolvedUri.uri} list={list} /> + ) : ( + <CenteredView> + <View style={s.p20}> + <ActivityIndicator size="large" /> + </View> + </CenteredView> + ) +} - const onPressAddUser = useCallback(() => { - store.shell.openModal({ - name: 'list-add-user', - list, - onAdd() { - if (list.isCuratelist) { - feed.refresh() - } - }, - }) - }, [store, list, feed]) +function ProfileListScreenLoaded({ + route, + uri, + list, +}: Props & {uri: string; list: AppBskyGraphDefs.ListView}) { + const {_} = useLingui() + const queryClient = useQueryClient() + const {openComposer} = useComposerControls() + const setMinimalShellMode = useSetMinimalShellMode() + const {rkey} = route.params + const feedSectionRef = React.useRef<SectionRef>(null) + const aboutSectionRef = React.useRef<SectionRef>(null) + const {openModal} = useModalControls() + const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' + + useSetTitle(list.name) + + useFocusEffect( + useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) - const onCurrentPageSelected = React.useCallback( - (index: number) => { - if (index === 0) { - feedSectionRef.current?.scrollToTop() - } - if (index === 1) { - aboutSectionRef.current?.scrollToTop() + const onPressAddUser = useCallback(() => { + openModal({ + name: 'list-add-remove-users', + list, + onChange() { + if (isCurateList) { + // TODO(eric) should construct these strings with a fn too + truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`)) } }, - [feedSectionRef], - ) + }) + }, [openModal, list, isCurateList, queryClient]) + + const onCurrentPageSelected = React.useCallback( + (index: number) => { + if (index === 0) { + feedSectionRef.current?.scrollToTop() + } else if (index === 1) { + aboutSectionRef.current?.scrollToTop() + } + }, + [feedSectionRef], + ) - const renderHeader = useCallback(() => { - return <Header rkey={rkey} list={list} /> - }, [rkey, list]) + const renderHeader = useCallback(() => { + return <Header rkey={rkey} list={list} /> + }, [rkey, list]) - if (list.isCuratelist) { - return ( - <View style={s.hContentRegion}> - <PagerWithHeader - items={SECTION_TITLES_CURATE} - isHeaderReady={list.hasLoaded} - renderHeader={renderHeader} - onCurrentPageSelected={onCurrentPageSelected}> - {({onScroll, headerHeight, isScrolledDown}) => ( - <FeedSection - ref={feedSectionRef} - feed={feed} - onScroll={onScroll} - headerHeight={headerHeight} - isScrolledDown={isScrolledDown} - /> - )} - {({onScroll, headerHeight, isScrolledDown}) => ( - <AboutSection - ref={aboutSectionRef} - list={list} - descriptionRT={list.descriptionRT} - creator={list.data ? list.data.creator : undefined} - isCurateList={list.isCuratelist} - isOwner={list.isOwner} - onPressAddUser={onPressAddUser} - onScroll={onScroll} - headerHeight={headerHeight} - isScrolledDown={isScrolledDown} - /> - )} - </PagerWithHeader> - <FAB - testID="composeFAB" - onPress={() => store.shell.openComposer({})} - icon={ - <ComposeIcon2 - strokeWidth={1.5} - size={29} - style={{color: 'white'}} - /> - } - accessibilityRole="button" - accessibilityLabel="New post" - accessibilityHint="" - /> - </View> - ) - } - if (list.isModlist) { - return ( - <View style={s.hContentRegion}> - <PagerWithHeader - items={SECTION_TITLES_MOD} - isHeaderReady={list.hasLoaded} - renderHeader={renderHeader}> - {({onScroll, headerHeight, isScrolledDown}) => ( - <AboutSection - list={list} - descriptionRT={list.descriptionRT} - creator={list.data ? list.data.creator : undefined} - isCurateList={list.isCuratelist} - isOwner={list.isOwner} - onPressAddUser={onPressAddUser} - onScroll={onScroll} - headerHeight={headerHeight} - isScrolledDown={isScrolledDown} - /> - )} - </PagerWithHeader> - <FAB - testID="composeFAB" - onPress={() => store.shell.openComposer({})} - icon={ - <ComposeIcon2 - strokeWidth={1.5} - size={29} - style={{color: 'white'}} - /> - } - accessibilityRole="button" - accessibilityLabel="New post" - accessibilityHint="" - /> - </View> - ) - } + if (isCurateList) { return ( - <CenteredView sideBorders style={s.hContentRegion}> - <Header rkey={rkey} list={list} /> - {list.error ? <ErrorScreen error={list.error} /> : null} - </CenteredView> + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES_CURATE} + isHeaderReady={true} + renderHeader={renderHeader} + onCurrentPageSelected={onCurrentPageSelected}> + {({ + onScroll, + headerHeight, + isScrolledDown, + scrollElRef, + isFocused, + }) => ( + <FeedSection + ref={feedSectionRef} + feed={`list|${uri}`} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + isFocused={isFocused} + /> + )} + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + <AboutSection + ref={aboutSectionRef} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + list={list} + onPressAddUser={onPressAddUser} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + </PagerWithHeader> + <FAB + testID="composeFAB" + onPress={() => openComposer({})} + icon={ + <ComposeIcon2 + strokeWidth={1.5} + size={29} + style={{color: 'white'}} + /> + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + </View> ) - }, -) + } + return ( + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES_MOD} + isHeaderReady={true} + renderHeader={renderHeader}> + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + <AboutSection + list={list} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + onPressAddUser={onPressAddUser} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + </PagerWithHeader> + <FAB + testID="composeFAB" + onPress={() => openComposer({})} + icon={ + <ComposeIcon2 strokeWidth={1.5} size={29} style={{color: 'white'}} /> + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + </View> + ) +} -const Header = observer(function HeaderImpl({ - rkey, - list, -}: { - rkey: string - list: ListModel -}) { +function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const pal = usePalette('default') const palInverted = usePalette('inverted') - const store = useStores() + const {_} = useLingui() const navigation = useNavigation<NavigationProp>() + const {currentAccount} = useSession() + const {openModal, closeModal} = useModalControls() + const listMuteMutation = useListMuteMutation() + const listBlockMutation = useListBlockMutation() + const listDeleteMutation = useListDeleteMutation() + const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' + const isModList = list.purpose === 'app.bsky.graph.defs#modlist' + const isPinned = false // TODO + const isBlocking = !!list.viewer?.blocked + const isMuting = !!list.viewer?.muted + const isOwner = list.creator.did === currentAccount?.did const onTogglePinned = useCallback(async () => { Haptics.default() - list.togglePin().catch(e => { - Toast.show('There was an issue contacting the server') - logger.error('Failed to toggle pinned list', {error: e}) - }) - }, [list]) + // TODO + // list.togglePin().catch(e => { + // Toast.show('There was an issue contacting the server') + // logger.error('Failed to toggle pinned list', {error: e}) + // }) + }, []) const onSubscribeMute = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Mute these accounts?', - message: - 'Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.', + title: _(msg`Mute these accounts?`), + message: _( + msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`, + ), confirmBtnText: 'Mute this List', async onPressConfirm() { try { - await list.mute() + await listMuteMutation.mutateAsync({uri: list.uri, mute: true}) Toast.show('List muted') } catch { Toast.show( @@ -297,32 +287,33 @@ const Header = observer(function HeaderImpl({ } }, onPressCancel() { - store.shell.closeModal() + closeModal() }, }) - }, [store, list]) + }, [openModal, closeModal, list, listMuteMutation, _]) const onUnsubscribeMute = useCallback(async () => { try { - await list.unmute() + await listMuteMutation.mutateAsync({uri: list.uri, mute: false}) Toast.show('List unmuted') } catch { Toast.show( 'There was an issue. Please check your internet connection and try again.', ) } - }, [list]) + }, [list, listMuteMutation]) const onSubscribeBlock = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Block these accounts?', - message: - 'Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.', + title: _(msg`Block these accounts?`), + message: _( + msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, + ), confirmBtnText: 'Block this List', async onPressConfirm() { try { - await list.block() + await listBlockMutation.mutateAsync({uri: list.uri, block: true}) Toast.show('List blocked') } catch { Toast.show( @@ -331,39 +322,36 @@ const Header = observer(function HeaderImpl({ } }, onPressCancel() { - store.shell.closeModal() + closeModal() }, }) - }, [store, list]) + }, [openModal, closeModal, list, listBlockMutation, _]) const onUnsubscribeBlock = useCallback(async () => { try { - await list.unblock() + await listBlockMutation.mutateAsync({uri: list.uri, block: false}) Toast.show('List unblocked') } catch { Toast.show( 'There was an issue. Please check your internet connection and try again.', ) } - }, [list]) + }, [list, listBlockMutation]) const onPressEdit = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'create-or-edit-list', list, - onSave() { - list.refresh() - }, }) - }, [store, list]) + }, [openModal, list]) const onPressDelete = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Delete List', - message: 'Are you sure?', + title: _(msg`Delete List`), + message: _(msg`Are you sure?`), async onPressConfirm() { - await list.delete() + await listDeleteMutation.mutateAsync({uri: list.uri}) Toast.show('List deleted') if (navigation.canGoBack()) { navigation.goBack() @@ -372,30 +360,26 @@ const Header = observer(function HeaderImpl({ } }, }) - }, [store, list, navigation]) + }, [openModal, list, listDeleteMutation, navigation, _]) const onPressReport = useCallback(() => { - if (!list.data) return - store.shell.openModal({ + openModal({ name: 'report', uri: list.uri, - cid: list.data.cid, + cid: list.cid, }) - }, [store, list]) + }, [openModal, list]) const onPressShare = useCallback(() => { - const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`) + const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`) shareUrl(url) - }, [list.creatorDid, rkey]) + }, [list, rkey]) const dropdownItems: DropdownItem[] = useMemo(() => { - if (!list.hasLoaded) { - return [] - } let items: DropdownItem[] = [ { testID: 'listHeaderDropdownShareBtn', - label: 'Share', + label: isWeb ? _(msg`Copy link to list`) : _(msg`Share`), onPress: onPressShare, icon: { ios: { @@ -406,11 +390,11 @@ const Header = observer(function HeaderImpl({ }, }, ] - if (list.isOwner) { + if (isOwner) { items.push({label: 'separator'}) items.push({ testID: 'listHeaderDropdownEditBtn', - label: 'Edit List Details', + label: _(msg`Edit list details`), onPress: onPressEdit, icon: { ios: { @@ -422,7 +406,7 @@ const Header = observer(function HeaderImpl({ }) items.push({ testID: 'listHeaderDropdownDeleteBtn', - label: 'Delete List', + label: _(msg`Delete List`), onPress: onPressDelete, icon: { ios: { @@ -436,7 +420,7 @@ const Header = observer(function HeaderImpl({ items.push({label: 'separator'}) items.push({ testID: 'listHeaderDropdownReportBtn', - label: 'Report List', + label: _(msg`Report List`), onPress: onPressReport, icon: { ios: { @@ -448,20 +432,13 @@ const Header = observer(function HeaderImpl({ }) } return items - }, [ - list.hasLoaded, - list.isOwner, - onPressShare, - onPressEdit, - onPressDelete, - onPressReport, - ]) + }, [isOwner, onPressShare, onPressEdit, onPressDelete, onPressReport, _]) const subscribeDropdownItems: DropdownItem[] = useMemo(() => { return [ { testID: 'subscribeDropdownMuteBtn', - label: 'Mute accounts', + label: _(msg`Mute accounts`), onPress: onSubscribeMute, icon: { ios: { @@ -473,7 +450,7 @@ const Header = observer(function HeaderImpl({ }, { testID: 'subscribeDropdownBlockBtn', - label: 'Block accounts', + label: _(msg`Block accounts`), onPress: onSubscribeBlock, icon: { ios: { @@ -484,36 +461,32 @@ const Header = observer(function HeaderImpl({ }, }, ] - }, [onSubscribeMute, onSubscribeBlock]) + }, [onSubscribeMute, onSubscribeBlock, _]) return ( <ProfileSubpageHeader - isLoading={!list.hasLoaded} - href={makeListLink( - list.data?.creator.handle || list.data?.creator.did || '', - rkey, - )} - title={list.data?.name || 'User list'} - avatar={list.data?.avatar} - isOwner={list.isOwner} - creator={list.data?.creator} + href={makeListLink(list.creator.handle || list.creator.did || '', rkey)} + title={list.name} + avatar={list.avatar} + isOwner={list.creator.did === currentAccount?.did} + creator={list.creator} avatarType="list"> - {list.isCuratelist || list.isPinned ? ( + {isCurateList || isPinned ? ( <Button testID={list.isPinned ? 'unpinBtn' : 'pinBtn'} type={list.isPinned ? 'default' : 'inverted'} label={list.isPinned ? 'Unpin' : 'Pin to home'} onPress={onTogglePinned} /> - ) : list.isModlist ? ( - list.isBlocking ? ( + ) : isModList ? ( + isBlocking ? ( <Button testID="unblockBtn" type="default" label="Unblock" onPress={onUnsubscribeBlock} /> - ) : list.isMuting ? ( + ) : isMuting ? ( <Button testID="unmuteBtn" type="default" @@ -524,10 +497,12 @@ const Header = observer(function HeaderImpl({ <NativeDropdown testID="subscribeBtn" items={subscribeDropdownItems} - accessibilityLabel="Subscribe to this list" + accessibilityLabel={_(msg`Subscribe to this list`)} accessibilityHint=""> <View style={[palInverted.view, styles.btn]}> - <Text style={palInverted.text}>Subscribe</Text> + <Text style={palInverted.text}> + <Trans>Subscribe</Trans> + </Text> </View> </NativeDropdown> ) @@ -535,7 +510,7 @@ const Header = observer(function HeaderImpl({ <NativeDropdown testID="headerDropdownBtn" items={dropdownItems} - accessibilityLabel="More options" + accessibilityLabel={_(msg`More options`)} accessibilityHint=""> <View style={[pal.viewLight, styles.btn]}> <FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} /> @@ -543,26 +518,29 @@ const Header = observer(function HeaderImpl({ </NativeDropdown> </ProfileSubpageHeader> ) -}) +} interface FeedSectionProps { - feed: PostsFeedModel - onScroll: (e: NativeScrollEvent) => void + feed: FeedDescriptor + onScroll: OnScrollHandler headerHeight: number isScrolledDown: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | null> + isFocused: boolean } const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( function FeedSectionImpl( - {feed, onScroll, headerHeight, isScrolledDown}, + {feed, scrollElRef, onScroll, headerHeight, isScrolledDown, isFocused}, ref, ) { - const hasNew = feed.hasNewLatest && !feed.isRefreshing - const scrollElRef = React.useRef<FlatList>(null) + const queryClient = useQueryClient() + const [hasNew, setHasNew] = React.useState(false) const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) - feed.refresh() - }, [feed, scrollElRef, headerHeight]) + queryClient.resetQueries({queryKey: FEED_RQKEY(feed)}) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) React.useImperativeHandle(ref, () => ({ scrollToTop: onScrollToTop, })) @@ -571,14 +549,16 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( return <EmptyState icon="feed" message="This feed is empty!" /> }, []) - const scrollHandler = useAnimatedScrollHandler({onScroll}) return ( <View> <Feed testID="listFeed" + enabled={isFocused} feed={feed} + pollInterval={30e3} scrollElRef={scrollElRef} - onScroll={scrollHandler} + onHasNew={setHasNew} + onScroll={onScroll} scrollEventThrottle={1} renderEmptyState={renderPostsEmpty} headerOffset={headerHeight} @@ -596,34 +576,35 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( ) interface AboutSectionProps { - list: ListModel - descriptionRT: RichTextAPI | null - creator: {did: string; handle: string} | undefined - isCurateList: boolean | undefined - isOwner: boolean | undefined + list: AppBskyGraphDefs.ListView onPressAddUser: () => void - onScroll: (e: NativeScrollEvent) => void + onScroll: OnScrollHandler headerHeight: number isScrolledDown: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | null> } const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( function AboutSectionImpl( - { - list, - descriptionRT, - creator, - isCurateList, - isOwner, - onPressAddUser, - onScroll, - headerHeight, - isScrolledDown, - }, + {list, onPressAddUser, onScroll, headerHeight, isScrolledDown, scrollElRef}, ref, ) { const pal = usePalette('default') + const {_} = useLingui() const {isMobile} = useWebMediaQueries() - const scrollElRef = React.useRef<FlatList>(null) + const {currentAccount} = useSession() + const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' + const isOwner = list.creator.did === currentAccount?.did + + const descriptionRT = useMemo( + () => + list.description + ? new RichTextAPI({ + text: list.description, + facets: list.descriptionFacets, + }) + : undefined, + [list], + ) const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) @@ -634,9 +615,6 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( })) const renderHeader = React.useCallback(() => { - if (!list.data) { - return <View /> - } return ( <View> <View @@ -660,7 +638,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( testID="listDescriptionEmpty" type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}> - No description + <Trans>No description</Trans> </Text> )} <Text type="md" style={[pal.textLight]} numberOfLines={1}> @@ -669,8 +647,8 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( 'you' ) : ( <TextLink - text={sanitizeHandle(creator?.handle || '', '@')} - href={creator ? makeProfileLink(creator) : ''} + text={sanitizeHandle(list.creator.handle || '', '@')} + href={makeProfileLink(list.creator)} style={pal.textLight} /> )} @@ -686,12 +664,14 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( paddingBottom: isMobile ? 14 : 18, }, ]}> - <Text type="lg-bold">Users</Text> + <Text type="lg-bold"> + <Trans>Users</Trans> + </Text> {isOwner && ( <Pressable testID="addUserBtn" accessibilityRole="button" - accessibilityLabel="Add a user to this list" + accessibilityLabel={_(msg`Add a user to this list`)} accessibilityHint="" onPress={onPressAddUser} style={{flexDirection: 'row', alignItems: 'center', gap: 6}}> @@ -700,7 +680,9 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( color={pal.colors.link} size={16} /> - <Text style={pal.link}>Add</Text> + <Text style={pal.link}> + <Trans>Add</Trans> + </Text> </Pressable> )} </View> @@ -708,13 +690,13 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( ) }, [ pal, - list.data, + list, isMobile, descriptionRT, - creator, isCurateList, isOwner, onPressAddUser, + _, ]) const renderEmptyState = useCallback(() => { @@ -727,17 +709,16 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( ) }, []) - const scrollHandler = useAnimatedScrollHandler({onScroll}) return ( <View> - <ListItems + <ListMembers testID="listItems" + list={list.uri} scrollElRef={scrollElRef} renderHeader={renderHeader} renderEmptyState={renderEmptyState} - list={list} headerOffset={headerHeight} - onScroll={scrollHandler} + onScroll={onScroll} scrollEventThrottle={1} /> {isScrolledDown && ( @@ -755,6 +736,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( function ErrorScreen({error}: {error: string}) { const pal = usePalette('default') const navigation = useNavigation<NavigationProp>() + const {_} = useLingui() const onPressBack = useCallback(() => { if (navigation.canGoBack()) { navigation.goBack() @@ -776,7 +758,7 @@ function ErrorScreen({error}: {error: string}) { }, ]}> <Text type="title-lg" style={[pal.text, s.mb10]}> - Could not load list + <Trans>Could not load list</Trans> </Text> <Text type="md" style={[pal.text, s.mb20]}> {error} @@ -785,12 +767,12 @@ function ErrorScreen({error}: {error: string}) { <View style={{flexDirection: 'row'}}> <Button type="default" - accessibilityLabel="Go Back" + accessibilityLabel={_(msg`Go Back`)} accessibilityHint="Return to previous page" onPress={onPressBack} style={{flexShrink: 1}}> <Text type="button" style={pal.text}> - Go Back + <Trans>Go Back</Trans> </Text> </Button> </View> diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index 487f56643..858a58a3c 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -1,33 +1,32 @@ -import React, {useCallback, useMemo} from 'react' -import { - StyleSheet, - View, - ActivityIndicator, - Pressable, - TouchableOpacity, -} from 'react-native' +import React from 'react' +import {StyleSheet, View, ActivityIndicator, Pressable} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {track} from '#/lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {CommonNavigatorParams} from 'lib/routes/types' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' -import {SavedFeedsModel} from 'state/models/ui/saved-feeds' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from 'view/com/util/ViewHeader' import {ScrollView, CenteredView} from 'view/com/util/Views' import {Text} from 'view/com/util/text/Text' import {s, colors} from 'lib/styles' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' -import {FeedSourceModel} from 'state/models/content/feed-source' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import * as Toast from 'view/com/util/Toast' import {Haptics} from 'lib/haptics' import {TextLink} from 'view/com/util/Link' import {logger} from '#/logger' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + usePreferencesQuery, + usePinFeedMutation, + useUnpinFeedMutation, + useSetSaveFeedsMutation, +} from '#/state/queries/preferences' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' const HITSLOP_TOP = { top: 20, @@ -43,99 +42,118 @@ const HITSLOP_BOTTOM = { } type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> -export const SavedFeeds = withAuthRequired( - observer(function SavedFeedsImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const {isMobile, isTabletOrDesktop} = useWebMediaQueries() - const {screen} = useAnalytics() - const setMinimalShellMode = useSetMinimalShellMode() +export function SavedFeeds({}: Props) { + const pal = usePalette('default') + const {_} = useLingui() + const {isMobile, isTabletOrDesktop} = useWebMediaQueries() + const {screen} = useAnalytics() + const setMinimalShellMode = useSetMinimalShellMode() + const {data: preferences} = usePreferencesQuery() + const { + mutateAsync: setSavedFeeds, + variables: optimisticSavedFeedsResponse, + reset: resetSaveFeedsMutationState, + error: setSavedFeedsError, + } = useSetSaveFeedsMutation() + + /* + * Use optimistic data if exists and no error, otherwise fallback to remote + * data + */ + const currentFeeds = + optimisticSavedFeedsResponse && !setSavedFeedsError + ? optimisticSavedFeedsResponse + : preferences?.feeds || {saved: [], pinned: []} + const unpinned = currentFeeds.saved.filter(f => { + return !currentFeeds.pinned?.includes(f) + }) - const savedFeeds = useMemo(() => { - const model = new SavedFeedsModel(store) - model.refresh() - return model - }, [store]) - useFocusEffect( - useCallback(() => { - screen('SavedFeeds') - setMinimalShellMode(false) - savedFeeds.refresh() - }, [screen, setMinimalShellMode, savedFeeds]), - ) + useFocusEffect( + React.useCallback(() => { + screen('SavedFeeds') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) - return ( - <CenteredView - style={[ - s.hContentRegion, - pal.border, - isTabletOrDesktop && styles.desktopContainer, - ]}> - <ViewHeader title="Edit My Feeds" showOnDesktop showBorder /> - <ScrollView style={s.flex1}> - <View style={[pal.text, pal.border, styles.title]}> - <Text type="title" style={pal.text}> - Pinned Feeds - </Text> - </View> - {savedFeeds.hasLoaded ? ( - !savedFeeds.pinned.length ? ( - <View - style={[ - pal.border, - isMobile && s.flex1, - pal.viewLight, - styles.empty, - ]}> - <Text type="lg" style={[pal.text]}> - You don't have any pinned feeds. - </Text> - </View> - ) : ( - savedFeeds.pinned.map(feed => ( - <ListItem - key={feed._reactKey} - savedFeeds={savedFeeds} - item={feed} - /> - )) - ) + return ( + <CenteredView + style={[ + s.hContentRegion, + pal.border, + isTabletOrDesktop && styles.desktopContainer, + ]}> + <ViewHeader title={_(msg`Edit My Feeds`)} showOnDesktop showBorder /> + <ScrollView style={s.flex1}> + <View style={[pal.text, pal.border, styles.title]}> + <Text type="title" style={pal.text}> + <Trans>Pinned Feeds</Trans> + </Text> + </View> + {preferences?.feeds ? ( + !currentFeeds.pinned.length ? ( + <View + style={[ + pal.border, + isMobile && s.flex1, + pal.viewLight, + styles.empty, + ]}> + <Text type="lg" style={[pal.text]}> + <Trans>You don't have any pinned feeds.</Trans> + </Text> + </View> ) : ( - <ActivityIndicator style={{marginTop: 20}} /> - )} - <View style={[pal.text, pal.border, styles.title]}> - <Text type="title" style={pal.text}> - Saved Feeds - </Text> - </View> - {savedFeeds.hasLoaded ? ( - !savedFeeds.unpinned.length ? ( - <View - style={[ - pal.border, - isMobile && s.flex1, - pal.viewLight, - styles.empty, - ]}> - <Text type="lg" style={[pal.text]}> - You don't have any saved feeds. - </Text> - </View> - ) : ( - savedFeeds.unpinned.map(feed => ( - <ListItem - key={feed._reactKey} - savedFeeds={savedFeeds} - item={feed} - /> - )) - ) + currentFeeds.pinned.map(uri => ( + <ListItem + key={uri} + feedUri={uri} + isPinned + setSavedFeeds={setSavedFeeds} + resetSaveFeedsMutationState={resetSaveFeedsMutationState} + currentFeeds={currentFeeds} + /> + )) + ) + ) : ( + <ActivityIndicator style={{marginTop: 20}} /> + )} + <View style={[pal.text, pal.border, styles.title]}> + <Text type="title" style={pal.text}> + <Trans>Saved Feeds</Trans> + </Text> + </View> + {preferences?.feeds ? ( + !unpinned.length ? ( + <View + style={[ + pal.border, + isMobile && s.flex1, + pal.viewLight, + styles.empty, + ]}> + <Text type="lg" style={[pal.text]}> + <Trans>You don't have any saved feeds.</Trans> + </Text> + </View> ) : ( - <ActivityIndicator style={{marginTop: 20}} /> - )} + unpinned.map(uri => ( + <ListItem + key={uri} + feedUri={uri} + isPinned={false} + setSavedFeeds={setSavedFeeds} + resetSaveFeedsMutationState={resetSaveFeedsMutationState} + currentFeeds={currentFeeds} + /> + )) + ) + ) : ( + <ActivityIndicator style={{marginTop: 20}} /> + )} - <View style={styles.footerText}> - <Text type="sm" style={pal.textLight}> + <View style={styles.footerText}> + <Text type="sm" style={pal.textLight}> + <Trans> Feeds are custom algorithms that users build with a little coding expertise.{' '} <TextLink @@ -145,48 +163,95 @@ export const SavedFeeds = withAuthRequired( text="See this guide" />{' '} for more information. - </Text> - </View> - <View style={{height: 100}} /> - </ScrollView> - </CenteredView> - ) - }), -) + </Trans> + </Text> + </View> + <View style={{height: 100}} /> + </ScrollView> + </CenteredView> + ) +} -const ListItem = observer(function ListItemImpl({ - savedFeeds, - item, +function ListItem({ + feedUri, + isPinned, + currentFeeds, + setSavedFeeds, + resetSaveFeedsMutationState, }: { - savedFeeds: SavedFeedsModel - item: FeedSourceModel + feedUri: string // uri + isPinned: boolean + currentFeeds: {saved: string[]; pinned: string[]} + setSavedFeeds: ReturnType<typeof useSetSaveFeedsMutation>['mutateAsync'] + resetSaveFeedsMutationState: ReturnType< + typeof useSetSaveFeedsMutation + >['reset'] }) { const pal = usePalette('default') - const isPinned = item.isPinned + const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation() + const {isPending: isUnpinPending, mutateAsync: unpinFeed} = + useUnpinFeedMutation() + const isPending = isPinPending || isUnpinPending - const onTogglePinned = useCallback(() => { + const onTogglePinned = React.useCallback(async () => { Haptics.default() - item.togglePin().catch(e => { + + try { + resetSaveFeedsMutationState() + + if (isPinned) { + await unpinFeed({uri: feedUri}) + } else { + await pinFeed({uri: feedUri}) + } + } catch (e) { Toast.show('There was an issue contacting the server') logger.error('Failed to toggle pinned feed', {error: e}) - }) - }, [item]) - const onPressUp = useCallback( - () => - savedFeeds.movePinnedFeed(item, 'up').catch(e => { - Toast.show('There was an issue contacting the server') - logger.error('Failed to set pinned feed order', {error: e}) - }), - [savedFeeds, item], - ) - const onPressDown = useCallback( - () => - savedFeeds.movePinnedFeed(item, 'down').catch(e => { - Toast.show('There was an issue contacting the server') - logger.error('Failed to set pinned feed order', {error: e}) - }), - [savedFeeds, item], - ) + } + }, [feedUri, isPinned, pinFeed, unpinFeed, resetSaveFeedsMutationState]) + + const onPressUp = React.useCallback(async () => { + if (!isPinned) return + + // create new array, do not mutate + const pinned = [...currentFeeds.pinned] + const index = pinned.indexOf(feedUri) + + if (index === -1 || index === 0) return + ;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]] + + try { + await setSavedFeeds({saved: currentFeeds.saved, pinned}) + track('CustomFeed:Reorder', { + uri: feedUri, + index: pinned.indexOf(feedUri), + }) + } catch (e) { + Toast.show('There was an issue contacting the server') + logger.error('Failed to set pinned feed order', {error: e}) + } + }, [feedUri, isPinned, setSavedFeeds, currentFeeds]) + + const onPressDown = React.useCallback(async () => { + if (!isPinned) return + + const pinned = [...currentFeeds.pinned] + const index = pinned.indexOf(feedUri) + + if (index === -1 || index >= pinned.length - 1) return + ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]] + + try { + await setSavedFeeds({saved: currentFeeds.saved, pinned}) + track('CustomFeed:Reorder', { + uri: feedUri, + index: pinned.indexOf(feedUri), + }) + } catch (e) { + Toast.show('There was an issue contacting the server') + logger.error('Failed to set pinned feed order', {error: e}) + } + }, [feedUri, isPinned, setSavedFeeds, currentFeeds]) return ( <Pressable @@ -194,43 +259,62 @@ const ListItem = observer(function ListItemImpl({ style={[styles.itemContainer, pal.border]}> {isPinned ? ( <View style={styles.webArrowButtonsContainer}> - <TouchableOpacity + <Pressable + disabled={isPending} accessibilityRole="button" onPress={onPressUp} - hitSlop={HITSLOP_TOP}> + hitSlop={HITSLOP_TOP} + style={state => ({ + opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + })}> <FontAwesomeIcon icon="arrow-up" size={12} style={[pal.text, styles.webArrowUpButton]} /> - </TouchableOpacity> - <TouchableOpacity + </Pressable> + <Pressable + disabled={isPending} accessibilityRole="button" onPress={onPressDown} - hitSlop={HITSLOP_BOTTOM}> + hitSlop={HITSLOP_BOTTOM} + style={state => ({ + opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + })}> <FontAwesomeIcon icon="arrow-down" size={12} style={[pal.text]} /> - </TouchableOpacity> + </Pressable> </View> ) : null} <FeedSourceCard - key={item.uri} - item={item} - showSaveBtn + key={feedUri} + feedUri={feedUri} style={styles.noBorder} + showSaveBtn + LoadingComponent={ + <FeedLoadingPlaceholder + style={{flex: 1}} + showLowerPlaceholder={false} + showTopBorder={false} + /> + } /> - <TouchableOpacity + <Pressable + disabled={isPending} accessibilityRole="button" hitSlop={10} - onPress={onTogglePinned}> + onPress={onTogglePinned} + style={state => ({ + opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + })}> <FontAwesomeIcon icon="thumb-tack" size={20} color={isPinned ? colors.blue3 : pal.colors.icon} /> - </TouchableOpacity> + </Pressable> </Pressable> ) -}) +} const styles = StyleSheet.create({ desktopContainer: { diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx deleted file mode 100644 index bf9857df4..000000000 --- a/src/view/screens/Search.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './SearchMobile' diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx deleted file mode 100644 index 2d0c0288a..000000000 --- a/src/view/screens/Search.web.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react' -import {View, StyleSheet} from 'react-native' -import {SearchUIModel} from 'state/models/ui/search' -import {FoafsModel} from 'state/models/discovery/foafs' -import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {Suggestions} from 'view/com/search/Suggestions' -import {SearchResults} from 'view/com/search/SearchResults' -import {observer} from 'mobx-react-lite' -import { - NativeStackScreenProps, - SearchTabNavigatorParams, -} from 'lib/routes/types' -import {useStores} from 'state/index' -import {CenteredView} from 'view/com/util/Views' -import * as Mobile from './SearchMobile' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' - -type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> -export const SearchScreen = withAuthRequired( - observer(function SearchScreenImpl({navigation, route}: Props) { - const store = useStores() - const params = route.params || {} - const foafs = React.useMemo<FoafsModel>( - () => new FoafsModel(store), - [store], - ) - const suggestedActors = React.useMemo<SuggestedActorsModel>( - () => new SuggestedActorsModel(store), - [store], - ) - const searchUIModel = React.useMemo<SearchUIModel | undefined>( - () => (params.q ? new SearchUIModel(store) : undefined), - [params.q, store], - ) - - React.useEffect(() => { - if (params.q && searchUIModel) { - searchUIModel.fetch(params.q) - } - if (!foafs.hasData) { - foafs.fetch() - } - if (!suggestedActors.hasLoaded) { - suggestedActors.loadMore(true) - } - }, [foafs, suggestedActors, searchUIModel, params.q]) - - const {isDesktop} = useWebMediaQueries() - - if (searchUIModel) { - return ( - <View style={styles.scrollContainer}> - <SearchResults model={searchUIModel} /> - </View> - ) - } - - if (!isDesktop) { - return ( - <CenteredView style={styles.scrollContainer}> - <Mobile.SearchScreen navigation={navigation} route={route} /> - </CenteredView> - ) - } - - return <Suggestions foafs={foafs} suggestedActors={suggestedActors} /> - }), -) - -const styles = StyleSheet.create({ - scrollContainer: { - height: '100%', - overflowY: 'auto', - }, -}) diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx new file mode 100644 index 000000000..f031abcc2 --- /dev/null +++ b/src/view/screens/Search/Search.tsx @@ -0,0 +1,658 @@ +import React from 'react' +import { + View, + StyleSheet, + ActivityIndicator, + RefreshControl, + TextInput, + Pressable, + Platform, +} from 'react-native' +import {FlatList, ScrollView, CenteredView} from '#/view/com/util/Views' +import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {useFocusEffect} from '@react-navigation/native' + +import {logger} from '#/logger' +import { + NativeStackScreenProps, + SearchTabNavigatorParams, +} from 'lib/routes/types' +import {Text} from '#/view/com/util/text/Text' +import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {Post} from '#/view/com/post/Post' +import {Pager} from '#/view/com/pager/Pager' +import {TabBar} from '#/view/com/pager/TabBar' +import {HITSLOP_10} from '#/lib/constants' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {usePalette} from '#/lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {useSession} from '#/state/session' +import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows' +import {useSearchPostsQuery} from '#/state/queries/search-posts' +import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' +import {useSetDrawerOpen} from '#/state/shell' +import {useAnalytics} from '#/lib/analytics/analytics' +import {MagnifyingGlassIcon} from '#/lib/icons' +import {useModerationOpts} from '#/state/queries/preferences' +import {SearchResultCard} from '#/view/shell/desktop/Search' +import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' +import {isWeb} from '#/platform/detection' +import {listenSoftReset} from '#/state/events' +import {s} from '#/lib/styles' + +function Loader() { + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + return ( + <CenteredView + style={[ + // @ts-ignore web only -prf + { + padding: 18, + height: isWeb ? '100vh' : undefined, + }, + pal.border, + ]} + sideBorders={!isMobile}> + <ActivityIndicator /> + </CenteredView> + ) +} + +function EmptyState({message, error}: {message: string; error?: string}) { + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + + return ( + <CenteredView + sideBorders={!isMobile} + style={[ + pal.border, + // @ts-ignore web only -prf + { + padding: 18, + height: isWeb ? '100vh' : undefined, + }, + ]}> + <View style={[pal.viewLight, {padding: 18, borderRadius: 8}]}> + <Text style={[pal.text]}> + <Trans>{message}</Trans> + </Text> + + {error && ( + <> + <View + style={[ + { + marginVertical: 12, + height: 1, + width: '100%', + backgroundColor: pal.text.color, + opacity: 0.2, + }, + ]} + /> + + <Text style={[pal.textLight]}> + <Trans>Error:</Trans> {error} + </Text> + </> + )} + </View> + </CenteredView> + ) +} + +function SearchScreenSuggestedFollows() { + const pal = usePalette('default') + const {currentAccount} = useSession() + const [suggestions, setSuggestions] = React.useState< + AppBskyActorDefs.ProfileViewBasic[] + >([]) + const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor() + + React.useEffect(() => { + async function getSuggestions() { + const friends = await getSuggestedFollowsByActor( + currentAccount!.did, + ).then(friendsRes => friendsRes.suggestions) + + if (!friends) return // :( + + const friendsOfFriends = new Map< + string, + AppBskyActorDefs.ProfileViewBasic + >() + + await Promise.all( + friends.slice(0, 4).map(friend => + getSuggestedFollowsByActor(friend.did).then(foafsRes => { + for (const user of foafsRes.suggestions) { + friendsOfFriends.set(user.did, user) + } + }), + ), + ) + + setSuggestions(Array.from(friendsOfFriends.values())) + } + + try { + getSuggestions() + } catch (e) { + logger.error(`SearchScreenSuggestedFollows: failed to get suggestions`, { + error: e, + }) + } + }, [currentAccount, setSuggestions, getSuggestedFollowsByActor]) + + return suggestions.length ? ( + <FlatList + data={suggestions} + renderItem={({item}) => <ProfileCardWithFollowBtn profile={item} noBg />} + keyExtractor={item => item.did} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 1200}} + /> + ) : ( + <CenteredView sideBorders style={[pal.border, s.hContentRegion]}> + <ProfileCardFeedLoadingPlaceholder /> + <ProfileCardFeedLoadingPlaceholder /> + </CenteredView> + ) +} + +type SearchResultSlice = + | { + type: 'post' + key: string + post: AppBskyFeedDefs.PostView + } + | { + type: 'loadingMore' + key: string + } + +function SearchScreenPostResults({query}: {query: string}) { + const {_} = useLingui() + const pal = usePalette('default') + const [isPTR, setIsPTR] = React.useState(false) + const { + isFetched, + data: results, + isFetching, + error, + refetch, + fetchNextPage, + isFetchingNextPage, + hasNextPage, + } = useSearchPostsQuery({query}) + + const onPullToRefresh = React.useCallback(async () => { + setIsPTR(true) + await refetch() + setIsPTR(false) + }, [setIsPTR, refetch]) + const onEndReached = React.useCallback(() => { + if (isFetching || !hasNextPage || error) return + fetchNextPage() + }, [isFetching, error, hasNextPage, fetchNextPage]) + + const posts = React.useMemo(() => { + return results?.pages.flatMap(page => page.posts) || [] + }, [results]) + const items = React.useMemo(() => { + let temp: SearchResultSlice[] = [] + + for (const post of posts) { + temp.push({ + type: 'post', + key: post.uri, + post, + }) + } + + if (isFetchingNextPage) { + temp.push({ + type: 'loadingMore', + key: 'loadingMore', + }) + } + + return temp + }, [posts, isFetchingNextPage]) + + return error ? ( + <EmptyState + message={_( + msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`, + )} + error={error.toString()} + /> + ) : ( + <> + {isFetched ? ( + <> + {posts.length ? ( + <FlatList + data={items} + renderItem={({item}) => { + if (item.type === 'post') { + return <Post post={item.post} /> + } else { + return <Loader /> + } + }} + keyExtractor={item => item.key} + refreshControl={ + <RefreshControl + refreshing={isPTR} + onRefresh={onPullToRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onEndReached={onEndReached} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + <EmptyState message={_(msg`No results found for ${query}`)} /> + )} + </> + ) : ( + <Loader /> + )} + </> + ) +} + +function SearchScreenUserResults({query}: {query: string}) { + const {_} = useLingui() + const [isFetched, setIsFetched] = React.useState(false) + const [results, setResults] = React.useState< + AppBskyActorDefs.ProfileViewBasic[] + >([]) + const search = useActorAutocompleteFn() + + React.useEffect(() => { + async function getResults() { + try { + const searchResults = await search({query, limit: 30}) + + if (searchResults) { + setResults(searchResults) + } + } catch (e: any) { + logger.error(`SearchScreenUserResults: failed to get results`, { + error: e.toString(), + }) + } finally { + setIsFetched(true) + } + } + + if (query) { + getResults() + } else { + setResults([]) + setIsFetched(false) + } + }, [query, search, setResults]) + + return isFetched ? ( + <> + {results.length ? ( + <FlatList + data={results} + renderItem={({item}) => ( + <ProfileCardWithFollowBtn profile={item} noBg /> + )} + keyExtractor={item => item.did} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + <EmptyState message={_(msg`No results found for ${query}`)} /> + )} + </> + ) : ( + <Loader /> + ) +} + +const SECTIONS = ['Posts', 'Users'] +export function SearchScreenInner({query}: {query?: string}) { + const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() + const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + const {hasSession} = useSession() + const {isDesktop} = useWebMediaQueries() + + const onPageSelected = React.useCallback( + (index: number) => { + setMinimalShellMode(false) + setDrawerSwipeDisabled(index > 0) + }, + [setDrawerSwipeDisabled, setMinimalShellMode], + ) + + return query ? ( + <Pager + tabBarPosition="top" + onPageSelected={onPageSelected} + renderTabBar={props => ( + <CenteredView sideBorders style={pal.border}> + <TabBar items={SECTIONS} {...props} /> + </CenteredView> + )} + initialPage={0}> + <View> + <SearchScreenPostResults query={query} /> + </View> + <View> + <SearchScreenUserResults query={query} /> + </View> + </Pager> + ) : hasSession ? ( + <View> + <CenteredView sideBorders style={pal.border}> + <Text + type="title" + style={[ + pal.text, + pal.border, + { + display: 'flex', + paddingVertical: 12, + paddingHorizontal: 18, + fontWeight: 'bold', + }, + ]}> + <Trans>Suggested Follows</Trans> + </Text> + </CenteredView> + + <SearchScreenSuggestedFollows /> + </View> + ) : ( + <CenteredView sideBorders style={pal.border}> + <View + // @ts-ignore web only -esb + style={{ + height: Platform.select({web: '100vh'}), + }}> + {isDesktop && ( + <Text + type="title" + style={[ + pal.text, + pal.border, + { + display: 'flex', + paddingVertical: 12, + paddingHorizontal: 18, + fontWeight: 'bold', + borderBottomWidth: 1, + }, + ]}> + <Trans>Search</Trans> + </Text> + )} + + <Text + style={[ + pal.textLight, + {textAlign: 'center', paddingVertical: 12, paddingHorizontal: 18}, + ]}> + <Trans>Search for posts and users.</Trans> + </Text> + </View> + </CenteredView> + ) +} + +export function SearchScreenDesktop( + props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, +) { + const {isDesktop} = useWebMediaQueries() + + return isDesktop ? ( + <SearchScreenInner query={props.route.params?.q} /> + ) : ( + <SearchScreenMobile {...props} /> + ) +} + +export function SearchScreenMobile( + props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, +) { + const theme = useTheme() + const textInput = React.useRef<TextInput>(null) + const {_} = useLingui() + const pal = usePalette('default') + const {track} = useAnalytics() + const setDrawerOpen = useSetDrawerOpen() + const moderationOpts = useModerationOpts() + const search = useActorAutocompleteFn() + const setMinimalShellMode = useSetMinimalShellMode() + const {isTablet} = useWebMediaQueries() + + const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>( + undefined, + ) + const [isFetching, setIsFetching] = React.useState<boolean>(false) + const [query, setQuery] = React.useState<string>(props.route?.params?.q || '') + const [searchResults, setSearchResults] = React.useState< + AppBskyActorDefs.ProfileViewBasic[] + >([]) + const [inputIsFocused, setInputIsFocused] = React.useState(false) + const [showAutocompleteResults, setShowAutocompleteResults] = + React.useState(false) + + const onPressMenu = React.useCallback(() => { + track('ViewHeader:MenuButtonClicked') + setDrawerOpen(true) + }, [track, setDrawerOpen]) + const onPressCancelSearch = React.useCallback(() => { + textInput.current?.blur() + setQuery('') + setShowAutocompleteResults(false) + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + }, [textInput]) + const onPressClearQuery = React.useCallback(() => { + setQuery('') + setShowAutocompleteResults(false) + }, [setQuery]) + const onChangeText = React.useCallback( + async (text: string) => { + setQuery(text) + + if (text.length > 0) { + setIsFetching(true) + setShowAutocompleteResults(true) + + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + + searchDebounceTimeout.current = setTimeout(async () => { + const results = await search({query: text, limit: 30}) + + if (results) { + setSearchResults(results) + setIsFetching(false) + } + }, 300) + } else { + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + setSearchResults([]) + setIsFetching(false) + setShowAutocompleteResults(false) + } + }, + [setQuery, search, setSearchResults], + ) + const onSubmit = React.useCallback(() => { + setShowAutocompleteResults(false) + }, [setShowAutocompleteResults]) + + const onSoftReset = React.useCallback(() => { + onPressCancelSearch() + }, [onPressCancelSearch]) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + return listenSoftReset(onSoftReset) + }, [onSoftReset, setMinimalShellMode]), + ) + + return ( + <View style={{flex: 1}}> + <CenteredView style={[styles.header, pal.border]} sideBorders={isTablet}> + <Pressable + testID="viewHeaderBackOrMenuBtn" + onPress={onPressMenu} + hitSlop={HITSLOP_10} + style={styles.headerMenuBtn} + accessibilityRole="button" + accessibilityLabel={_(msg`Menu`)} + accessibilityHint="Access navigation links and settings"> + <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} /> + </Pressable> + + <View + style={[ + {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={() => setInputIsFocused(true)} + onBlur={() => setInputIsFocused(false)} + onChangeText={onChangeText} + onSubmitEditing={onSubmit} + autoFocus={false} + accessibilityRole="search" + accessibilityLabel={_(msg`Search`)} + accessibilityHint="" + autoCorrect={false} + autoCapitalize="none" + /> + {query ? ( + <Pressable + testID="searchTextInputClearBtn" + onPress={onPressClearQuery} + accessibilityRole="button" + accessibilityLabel={_(msg`Clear search query`)} + accessibilityHint=""> + <FontAwesomeIcon + icon="xmark" + size={16} + style={pal.textLight as FontAwesomeIconStyle} + /> + </Pressable> + ) : undefined} + </View> + + {query || inputIsFocused ? ( + <View style={styles.headerCancelBtn}> + <Pressable onPress={onPressCancelSearch} accessibilityRole="button"> + <Text style={[pal.text]}> + <Trans>Cancel</Trans> + </Text> + </Pressable> + </View> + ) : undefined} + </CenteredView> + + {showAutocompleteResults && moderationOpts ? ( + <> + {isFetching ? ( + <Loader /> + ) : ( + <ScrollView style={{height: '100%'}}> + {searchResults.length ? ( + searchResults.map((item, i) => ( + <SearchResultCard + key={item.did} + profile={item} + moderation={moderateProfile(item, moderationOpts)} + style={i === 0 ? {borderTopWidth: 0} : {}} + /> + )) + ) : ( + <EmptyState message={_(msg`No results found for ${query}`)} /> + )} + + <View style={{height: 200}} /> + </ScrollView> + )} + </> + ) : ( + <SearchScreenInner query={query} /> + )} + </View> + ) +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 4, + }, + 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, + }, +}) diff --git a/src/view/screens/Search/index.tsx b/src/view/screens/Search/index.tsx new file mode 100644 index 000000000..a65149bf7 --- /dev/null +++ b/src/view/screens/Search/index.tsx @@ -0,0 +1,3 @@ +import {SearchScreenMobile} from '#/view/screens/Search/Search' + +export const SearchScreen = SearchScreenMobile diff --git a/src/view/screens/Search/index.web.tsx b/src/view/screens/Search/index.web.tsx new file mode 100644 index 000000000..8e039e3cd --- /dev/null +++ b/src/view/screens/Search/index.web.tsx @@ -0,0 +1,3 @@ +import {SearchScreenDesktop} from '#/view/screens/Search/Search' + +export const SearchScreen = SearchScreenDesktop diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx deleted file mode 100644 index c1df58ffd..000000000 --- a/src/view/screens/SearchMobile.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import React, {useCallback} from 'react' -import { - StyleSheet, - TouchableWithoutFeedback, - Keyboard, - View, -} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {FlatList, ScrollView} from 'view/com/util/Views' -import { - NativeStackScreenProps, - SearchTabNavigatorParams, -} from 'lib/routes/types' -import {observer} from 'mobx-react-lite' -import {Text} from 'view/com/util/text/Text' -import {useStores} from 'state/index' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' -import {SearchUIModel} from 'state/models/ui/search' -import {FoafsModel} from 'state/models/discovery/foafs' -import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' -import {HeaderWithInput} from 'view/com/search/HeaderWithInput' -import {Suggestions} from 'view/com/search/Suggestions' -import {SearchResults} from 'view/com/search/SearchResults' -import {s} from 'lib/styles' -import {ProfileCard} from 'view/com/profile/ProfileCard' -import {usePalette} from 'lib/hooks/usePalette' -import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' -import {isAndroid, isIOS} from 'platform/detection' -import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' - -type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> -export const SearchScreen = withAuthRequired( - observer<Props>(function SearchScreenImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const setIsDrawerSwipeDisabled = useSetDrawerSwipeDisabled() - const scrollViewRef = React.useRef<ScrollView>(null) - const flatListRef = React.useRef<FlatList>(null) - const [onMainScroll] = useOnMainScroll() - const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) - const [query, setQuery] = React.useState<string>('') - const autocompleteView = React.useMemo<UserAutocompleteModel>( - () => new UserAutocompleteModel(store), - [store], - ) - const foafs = React.useMemo<FoafsModel>( - () => new FoafsModel(store), - [store], - ) - const suggestedActors = React.useMemo<SuggestedActorsModel>( - () => new SuggestedActorsModel(store), - [store], - ) - const [searchUIModel, setSearchUIModel] = React.useState< - SearchUIModel | undefined - >() - - const onChangeQuery = React.useCallback( - (text: string) => { - setQuery(text) - if (text.length > 0) { - autocompleteView.setActive(true) - autocompleteView.setPrefix(text) - } else { - autocompleteView.setActive(false) - } - }, - [setQuery, autocompleteView], - ) - - const onPressClearQuery = React.useCallback(() => { - setQuery('') - }, [setQuery]) - - const onPressCancelSearch = React.useCallback(() => { - setQuery('') - autocompleteView.setActive(false) - setSearchUIModel(undefined) - setIsDrawerSwipeDisabled(false) - }, [setQuery, autocompleteView, setIsDrawerSwipeDisabled]) - - const onSubmitQuery = React.useCallback(() => { - if (query.length === 0) { - return - } - - const model = new SearchUIModel(store) - model.fetch(query) - setSearchUIModel(model) - setIsDrawerSwipeDisabled(true) - }, [query, setSearchUIModel, store, setIsDrawerSwipeDisabled]) - - const onSoftReset = React.useCallback(() => { - scrollViewRef.current?.scrollTo({x: 0, y: 0}) - flatListRef.current?.scrollToOffset({offset: 0}) - onPressCancelSearch() - }, [scrollViewRef, flatListRef, onPressCancelSearch]) - - useFocusEffect( - React.useCallback(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) - const cleanup = () => { - softResetSub.remove() - } - - setMinimalShellMode(false) - autocompleteView.setup() - if (!foafs.hasData) { - foafs.fetch() - } - if (!suggestedActors.hasLoaded) { - suggestedActors.loadMore(true) - } - - return cleanup - }, [ - store, - autocompleteView, - foafs, - suggestedActors, - onSoftReset, - setMinimalShellMode, - ]), - ) - - const onPress = useCallback(() => { - if (isIOS || isAndroid) { - Keyboard.dismiss() - } - }, []) - - return ( - <TouchableWithoutFeedback onPress={onPress} accessible={false}> - <View style={[pal.view, styles.container]}> - <HeaderWithInput - isInputFocused={isInputFocused} - query={query} - setIsInputFocused={setIsInputFocused} - onChangeQuery={onChangeQuery} - onPressClearQuery={onPressClearQuery} - onPressCancelSearch={onPressCancelSearch} - onSubmitQuery={onSubmitQuery} - /> - {searchUIModel ? ( - <SearchResults model={searchUIModel} /> - ) : !isInputFocused && !query ? ( - <Suggestions - ref={flatListRef} - foafs={foafs} - suggestedActors={suggestedActors} - /> - ) : ( - <ScrollView - ref={scrollViewRef} - testID="searchScrollView" - style={pal.view} - onScroll={onMainScroll} - scrollEventThrottle={100}> - {query && autocompleteView.suggestions.length ? ( - <> - {autocompleteView.suggestions.map((suggestion, index) => ( - <ProfileCard - key={suggestion.did} - testID={`searchAutoCompleteResult-${suggestion.handle}`} - profile={suggestion} - noBorder={index === 0} - /> - ))} - </> - ) : query && !autocompleteView.suggestions.length ? ( - <View> - <Text style={[pal.textLight, styles.searchPrompt]}> - No results found for {autocompleteView.prefix} - </Text> - </View> - ) : isInputFocused ? ( - <View> - <Text style={[pal.textLight, styles.searchPrompt]}> - Search for users and posts on the network - </Text> - </View> - ) : null} - <View style={s.footerSpacer} /> - </ScrollView> - )} - </View> - </TouchableWithoutFeedback> - ) - }), -) - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - - searchPrompt: { - textAlign: 'center', - paddingTop: 10, - }, -}) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index ca4ef2a40..388a5d954 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -10,20 +10,13 @@ import { View, ViewStyle, } from 'react-native' -import { - useFocusEffect, - useNavigation, - StackActions, -} from '@react-navigation/native' +import {useFocusEffect, useNavigation} from '@react-navigation/native' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import * as AppInfo from 'lib/app-info' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {ScrollView} from '../com/util/Views' import {ViewHeader} from '../com/util/ViewHeader' @@ -39,662 +32,766 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher' import {useAnalytics} from 'lib/analytics/analytics' import {NavigationProp} from 'lib/routes/types' -import {pluralize} from 'lib/strings/helpers' import {HandIcon, HashtagIcon} from 'lib/icons' -import {formatCount} from 'view/com/util/numeric/format' import Clipboard from '@react-native-clipboard/clipboard' import {makeProfileLink} from 'lib/routes/links' import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' -import {logger} from '#/logger' +import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' +import {useModalControls} from '#/state/modals' import { useSetMinimalShellMode, useColorMode, useSetColorMode, + useOnboardingDispatch, } from '#/state/shell' +import { + useRequireAltTextEnabled, + useSetRequireAltTextEnabled, +} from '#/state/preferences' +import { + useSession, + useSessionApi, + SessionAccount, + getAgent, +} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' +import {useClearPreferencesMutation} from '#/state/queries/preferences' +import {useInviteCodesQuery} from '#/state/queries/invites' +import {clear as clearStorage} from '#/state/persisted/store' +import {clearLegacyStorage} from '#/state/persisted/legacy' // TEMPORARY (APP-700) // remove after backend testing finishes // -prf import {useDebugHeaderSetting} from 'lib/api/debug-appview-proxy-header' import {STATUS_PAGE_URL} from 'lib/constants' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import {useCloseAllActiveElements} from '#/state/util' + +function SettingsAccountCard({account}: {account: SessionAccount}) { + const pal = usePalette('default') + const {isSwitchingAccounts, currentAccount} = useSession() + const {logout} = useSessionApi() + const {data: profile} = useProfileQuery({did: account.did}) + const isCurrentAccount = account.did === currentAccount?.did + const {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}> + {profile?.displayName || account.handle} + </Text> + <Text type="sm" style={pal.textLight}> + {account.handle} + </Text> + </View> -type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> -export const SettingsScreen = withAuthRequired( - observer(function Settings({}: Props) { - const colorMode = useColorMode() - const setColorMode = useSetColorMode() - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const navigation = useNavigation<NavigationProp>() - const {isMobile} = useWebMediaQueries() - const {screen, track} = useAnalytics() - const [isSwitching, setIsSwitching, onPressSwitchAccount] = - useAccountSwitcher() - const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting( - store.agent, - ) + {isCurrentAccount ? ( + <TouchableOpacity + testID="signOutBtn" + onPress={logout} + accessibilityRole="button" + accessibilityLabel="Sign out" + accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}> + <Text type="lg" style={pal.link}> + Sign out + </Text> + </TouchableOpacity> + ) : ( + <AccountDropdownBtn account={account} /> + )} + </View> + ) + + return isCurrentAccount ? ( + <Link + href={makeProfileLink({ + did: currentAccount?.did, + handle: currentAccount?.handle, + })} + title="Your profile" + noFeedback> + {contents} + </Link> + ) : ( + <TouchableOpacity + testID={`switchToAccountBtn-${account.handle}`} + key={account.did} + onPress={ + isSwitchingAccounts ? undefined : () => onPressSwitchAccount(account) + } + accessibilityRole="button" + accessibilityLabel={`Switch to ${account.handle}`} + accessibilityHint="Switches the account you are logged in to"> + {contents} + </TouchableOpacity> + ) +} - const primaryBg = useCustomPalette<ViewStyle>({ - light: {backgroundColor: colors.blue0}, - dark: {backgroundColor: colors.blue6}, - }) - const primaryText = useCustomPalette<TextStyle>({ - light: {color: colors.blue3}, - dark: {color: colors.blue2}, +type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> +export function SettingsScreen({}: Props) { + const queryClient = useQueryClient() + const colorMode = useColorMode() + const setColorMode = useSetColorMode() + const pal = usePalette('default') + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const requireAltTextEnabled = useRequireAltTextEnabled() + const setRequireAltTextEnabled = useSetRequireAltTextEnabled() + const onboardingDispatch = useOnboardingDispatch() + const navigation = useNavigation<NavigationProp>() + const {isMobile} = useWebMediaQueries() + const {screen, track} = useAnalytics() + const {openModal} = useModalControls() + const {isSwitchingAccounts, accounts, currentAccount} = useSession() + const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting( + getAgent(), + ) + const {mutate: clearPreferences} = useClearPreferencesMutation() + const {data: invites} = useInviteCodesQuery() + const invitesAvailable = invites?.available?.length ?? 0 + const {setShowLoggedOut} = useLoggedOutViewControls() + const closeAllActiveElements = useCloseAllActiveElements() + + const primaryBg = useCustomPalette<ViewStyle>({ + light: {backgroundColor: colors.blue0}, + dark: {backgroundColor: colors.blue6}, + }) + const primaryText = useCustomPalette<TextStyle>({ + light: {color: colors.blue3}, + dark: {color: colors.blue2}, + }) + + const dangerBg = useCustomPalette<ViewStyle>({ + light: {backgroundColor: colors.red1}, + dark: {backgroundColor: colors.red7}, + }) + const dangerText = useCustomPalette<TextStyle>({ + light: {color: colors.red4}, + dark: {color: colors.red2}, + }) + + useFocusEffect( + React.useCallback(() => { + screen('Settings') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) + + const onPressAddAccount = React.useCallback(() => { + track('Settings:AddAccountButtonClicked') + setShowLoggedOut(true) + closeAllActiveElements() + }, [track, setShowLoggedOut, closeAllActiveElements]) + + const onPressChangeHandle = React.useCallback(() => { + track('Settings:ChangeHandleButtonClicked') + openModal({ + name: 'change-handle', + onChanged() { + if (currentAccount) { + // refresh my profile + queryClient.invalidateQueries({ + queryKey: RQKEY_PROFILE(currentAccount.did), + }) + } + }, }) + }, [track, queryClient, openModal, currentAccount]) - const dangerBg = useCustomPalette<ViewStyle>({ - light: {backgroundColor: colors.red1}, - dark: {backgroundColor: colors.red7}, - }) - const dangerText = useCustomPalette<TextStyle>({ - light: {color: colors.red4}, - dark: {color: colors.red2}, - }) + const onPressInviteCodes = React.useCallback(() => { + track('Settings:InvitecodesButtonClicked') + openModal({name: 'invite-codes'}) + }, [track, openModal]) - useFocusEffect( - React.useCallback(() => { - screen('Settings') - setMinimalShellMode(false) - }, [screen, setMinimalShellMode]), - ) + const onPressLanguageSettings = React.useCallback(() => { + navigation.navigate('LanguageSettings') + }, [navigation]) + + const onPressDeleteAccount = React.useCallback(() => { + openModal({name: 'delete-account'}) + }, [openModal]) - const onPressAddAccount = React.useCallback(() => { - track('Settings:AddAccountButtonClicked') - navigation.navigate('HomeTab') - navigation.dispatch(StackActions.popToTop()) - store.session.clear() - }, [track, navigation, store]) - - const onPressChangeHandle = React.useCallback(() => { - track('Settings:ChangeHandleButtonClicked') - store.shell.openModal({ - name: 'change-handle', - onChanged() { - setIsSwitching(true) - store.session.reloadFromServer().then( - () => { - setIsSwitching(false) - Toast.show('Your handle has been updated') - }, - err => { - logger.error('Failed to reload from server after handle update', { - error: err, - }) - setIsSwitching(false) - }, - ) - }, - }) - }, [track, store, setIsSwitching]) - - const onPressInviteCodes = React.useCallback(() => { - track('Settings:InvitecodesButtonClicked') - store.shell.openModal({name: 'invite-codes'}) - }, [track, store]) - - const onPressLanguageSettings = React.useCallback(() => { - navigation.navigate('LanguageSettings') - }, [navigation]) - - const onPressSignout = React.useCallback(() => { - track('Settings:SignOutButtonClicked') - store.session.logout() - }, [track, store]) - - const onPressDeleteAccount = React.useCallback(() => { - store.shell.openModal({name: 'delete-account'}) - }, [store]) - - const onPressResetPreferences = React.useCallback(async () => { - await store.preferences.reset() - Toast.show('Preferences reset') - }, [store]) - - const onPressResetOnboarding = React.useCallback(async () => { - store.onboarding.reset() - Toast.show('Onboarding reset') - }, [store]) - - const onPressBuildInfo = React.useCallback(() => { - Clipboard.setString( - `Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`, - ) - Toast.show('Copied build version to clipboard') - }, []) - - const openHomeFeedPreferences = React.useCallback(() => { - navigation.navigate('PreferencesHomeFeed') - }, [navigation]) - - const openThreadsPreferences = React.useCallback(() => { - navigation.navigate('PreferencesThreads') - }, [navigation]) - - const onPressAppPasswords = React.useCallback(() => { - navigation.navigate('AppPasswords') - }, [navigation]) - - const onPressSystemLog = React.useCallback(() => { - navigation.navigate('Log') - }, [navigation]) - - const onPressStorybook = React.useCallback(() => { - navigation.navigate('Debug') - }, [navigation]) - - const onPressSavedFeeds = React.useCallback(() => { - navigation.navigate('SavedFeeds') - }, [navigation]) - - const onPressStatusPage = React.useCallback(() => { - Linking.openURL(STATUS_PAGE_URL) - }, []) - - return ( - <View style={[s.hContentRegion]} testID="settingsScreen"> - <ViewHeader title="Settings" /> - <ScrollView - style={[s.hContentRegion]} - contentContainerStyle={isMobile && pal.viewLight} - scrollIndicatorInsets={{right: 1}}> - <View style={styles.spacer20} /> - {store.session.currentSession !== undefined ? ( - <> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Account + const onPressResetPreferences = React.useCallback(async () => { + clearPreferences() + }, [clearPreferences]) + + const onPressResetOnboarding = React.useCallback(async () => { + onboardingDispatch({type: 'start'}) + Toast.show('Onboarding reset') + }, [onboardingDispatch]) + + const onPressBuildInfo = React.useCallback(() => { + Clipboard.setString( + `Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`, + ) + Toast.show('Copied build version to clipboard') + }, []) + + const openHomeFeedPreferences = React.useCallback(() => { + navigation.navigate('PreferencesHomeFeed') + }, [navigation]) + + const openThreadsPreferences = React.useCallback(() => { + navigation.navigate('PreferencesThreads') + }, [navigation]) + + const onPressAppPasswords = React.useCallback(() => { + navigation.navigate('AppPasswords') + }, [navigation]) + + const onPressSystemLog = React.useCallback(() => { + navigation.navigate('Log') + }, [navigation]) + + const onPressStorybook = React.useCallback(() => { + navigation.navigate('Debug') + }, [navigation]) + + const onPressSavedFeeds = React.useCallback(() => { + navigation.navigate('SavedFeeds') + }, [navigation]) + + const onPressStatusPage = React.useCallback(() => { + Linking.openURL(STATUS_PAGE_URL) + }, []) + + const clearAllStorage = React.useCallback(async () => { + await clearStorage() + Toast.show(`Storage cleared, you need to restart the app now.`) + }, []) + const clearAllLegacyStorage = React.useCallback(async () => { + await clearLegacyStorage() + Toast.show(`Legacy storage cleared, you need to restart the app now.`) + }, []) + + return ( + <View style={[s.hContentRegion]} testID="settingsScreen"> + <ViewHeader title={_(msg`Settings`)} /> + <ScrollView + style={[s.hContentRegion]} + contentContainerStyle={isMobile && pal.viewLight} + scrollIndicatorInsets={{right: 1}}> + <View style={styles.spacer20} /> + {currentAccount ? ( + <> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Account</Trans> + </Text> + <View style={[styles.infoLine]}> + <Text type="lg-medium" style={pal.text}> + <Trans>Email:</Trans>{' '} </Text> - <View style={[styles.infoLine]}> - <Text type="lg-medium" style={pal.text}> - Email:{' '} - </Text> - {!store.session.emailNeedsConfirmation && ( - <> - <FontAwesomeIcon - icon="check" - size={10} - style={{color: colors.green3, marginRight: 2}} - /> - </> - )} - <Text type="lg" style={pal.text}> - {store.session.currentSession?.email}{' '} - </Text> - <Link - onPress={() => store.shell.openModal({name: 'change-email'})}> - <Text type="lg" style={pal.link}> - Change - </Text> - </Link> - </View> - <View style={[styles.infoLine]}> - <Text type="lg-medium" style={pal.text}> - Birthday:{' '} + {currentAccount.emailConfirmed && ( + <> + <FontAwesomeIcon + icon="check" + size={10} + style={{color: colors.green3, marginRight: 2}} + /> + </> + )} + <Text type="lg" style={pal.text}> + {currentAccount.email}{' '} + </Text> + <Link onPress={() => openModal({name: 'change-email'})}> + <Text type="lg" style={pal.link}> + <Trans>Change</Trans> </Text> - <Link - onPress={() => - store.shell.openModal({name: 'birth-date-settings'}) - }> - <Text type="lg" style={pal.link}> - Show - </Text> - </Link> - </View> - <View style={styles.spacer20} /> - <EmailConfirmationNotice /> - </> - ) : null} - <View style={[s.flexRow, styles.heading]}> - <Text type="xl-bold" style={pal.text}> - Signed in as - </Text> - <View style={s.flex1} /> - </View> - {isSwitching ? ( - <View style={[pal.view, styles.linkCard]}> - <ActivityIndicator /> + </Link> </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> - )} - {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} + <View style={[styles.infoLine]}> + <Text type="lg-medium" style={pal.text}> + <Trans>Birthday:</Trans>{' '} + </Text> + <Link onPress={() => openModal({name: 'birth-date-settings'})}> + <Text type="lg" style={pal.link}> + <Trans>Show</Trans> </Text> - </View> - <AccountDropdownBtn handle={account.handle} /> - </TouchableOpacity> - ))} - <TouchableOpacity - testID="switchToNewAccountBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressAddAccount} - accessibilityRole="button" - accessibilityLabel="Add account" - accessibilityHint="Create a new Bluesky account"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="plus" - style={pal.text as FontAwesomeIconStyle} - /> + </Link> </View> - <Text type="lg" style={pal.text}> - Add account - </Text> - </TouchableOpacity> + <View style={styles.spacer20} /> + + {!currentAccount.emailConfirmed && <EmailConfirmationNotice />} + </> + ) : null} + <View style={[s.flexRow, styles.heading]}> + <Text type="xl-bold" style={pal.text}> + <Trans>Signed in as</Trans> + </Text> + <View style={s.flex1} /> + </View> - <View style={styles.spacer20} /> + {isSwitchingAccounts ? ( + <View style={[pal.view, styles.linkCard]}> + <ActivityIndicator /> + </View> + ) : ( + <SettingsAccountCard account={currentAccount!} /> + )} + + {accounts + .filter(a => a.did !== currentAccount?.did) + .map(account => ( + <SettingsAccountCard key={account.did} account={account} /> + ))} - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Invite a Friend + <TouchableOpacity + testID="switchToNewAccountBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={isSwitchingAccounts ? undefined : onPressAddAccount} + accessibilityRole="button" + accessibilityLabel={_(msg`Add account`)} + accessibilityHint="Create a new Bluesky account"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="plus" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Add account</Trans> </Text> - <TouchableOpacity - testID="inviteFriendBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressInviteCodes} - accessibilityRole="button" - accessibilityLabel="Invite" - accessibilityHint="Opens invite code list"> - <View - style={[ - styles.iconContainer, - store.me.invitesAvailable > 0 ? primaryBg : pal.btn, - ]}> - <FontAwesomeIcon - icon="ticket" - style={ - (store.me.invitesAvailable > 0 - ? primaryText - : pal.text) as FontAwesomeIconStyle - } - /> - </View> - <Text - type="lg" - style={store.me.invitesAvailable > 0 ? pal.link : pal.text}> - {formatCount(store.me.invitesAvailable)} invite{' '} - {pluralize(store.me.invitesAvailable, 'code')} available - </Text> - </TouchableOpacity> + </TouchableOpacity> - <View style={styles.spacer20} /> + <View style={styles.spacer20} /> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Accessibility - </Text> - <View style={[pal.view, styles.toggleCard]}> - <ToggleButton - type="default-light" - label="Require alt text before posting" - labelType="lg" - isSelected={store.preferences.requireAltTextEnabled} - onPress={store.preferences.toggleRequireAltTextEnabled} + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Invite a Friend</Trans> + </Text> + + <TouchableOpacity + testID="inviteFriendBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={isSwitchingAccounts ? undefined : onPressInviteCodes} + accessibilityRole="button" + accessibilityLabel={_(msg`Invite`)} + accessibilityHint="Opens invite code list" + disabled={invites?.disabled}> + <View + style={[ + styles.iconContainer, + invitesAvailable > 0 ? primaryBg : pal.btn, + ]}> + <FontAwesomeIcon + icon="ticket" + style={ + (invitesAvailable > 0 + ? primaryText + : pal.text) as FontAwesomeIconStyle + } /> </View> + <Text type="lg" style={invitesAvailable > 0 ? pal.link : pal.text}> + {invites?.disabled ? ( + <Trans> + Your invite codes are hidden when logged in using an App + Password + </Trans> + ) : invitesAvailable === 1 ? ( + <Trans>{invitesAvailable} invite code available</Trans> + ) : ( + <Trans>{invitesAvailable} invite codes available</Trans> + )} + </Text> + </TouchableOpacity> - <View style={styles.spacer20} /> + <View style={styles.spacer20} /> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Appearance - </Text> - <View> - <View style={[styles.linkCard, pal.view, styles.selectableBtns]}> - <SelectableBtn - selected={colorMode === 'system'} - label="System" - left - onSelect={() => setColorMode('system')} - accessibilityHint="Set color theme to system setting" - /> - <SelectableBtn - selected={colorMode === 'light'} - label="Light" - onSelect={() => setColorMode('light')} - accessibilityHint="Set color theme to light" - /> - <SelectableBtn - selected={colorMode === 'dark'} - label="Dark" - right - onSelect={() => setColorMode('dark')} - accessibilityHint="Set color theme to dark" - /> - </View> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Accessibility</Trans> + </Text> + <View style={[pal.view, styles.toggleCard]}> + <ToggleButton + type="default-light" + label="Require alt text before posting" + labelType="lg" + isSelected={requireAltTextEnabled} + onPress={() => setRequireAltTextEnabled(!requireAltTextEnabled)} + /> + </View> + + <View style={styles.spacer20} /> + + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Appearance</Trans> + </Text> + <View> + <View style={[styles.linkCard, pal.view, styles.selectableBtns]}> + <SelectableBtn + selected={colorMode === 'system'} + label="System" + left + onSelect={() => setColorMode('system')} + accessibilityHint="Set color theme to system setting" + /> + <SelectableBtn + selected={colorMode === 'light'} + label="Light" + onSelect={() => setColorMode('light')} + accessibilityHint="Set color theme to light" + /> + <SelectableBtn + selected={colorMode === 'dark'} + label="Dark" + right + onSelect={() => setColorMode('dark')} + accessibilityHint="Set color theme to dark" + /> </View> - <View style={styles.spacer20} /> + </View> + <View style={styles.spacer20} /> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Basics + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Basics</Trans> + </Text> + <TouchableOpacity + testID="preferencesHomeFeedButton" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={openHomeFeedPreferences} + accessibilityRole="button" + accessibilityHint="" + accessibilityLabel={_(msg`Opens the home feed preferences`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="sliders" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Home Feed Preferences</Trans> </Text> - <TouchableOpacity - testID="preferencesHomeFeedButton" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={openHomeFeedPreferences} - accessibilityRole="button" - accessibilityHint="" - accessibilityLabel="Opens the home feed preferences"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="sliders" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Home Feed Preferences - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="preferencesThreadsButton" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={openThreadsPreferences} - accessibilityRole="button" - accessibilityHint="" - accessibilityLabel="Opens the threads preferences"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon={['far', 'comments']} - style={pal.text as FontAwesomeIconStyle} - size={18} - /> - </View> - <Text type="lg" style={pal.text}> - Thread Preferences - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="savedFeedsBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - accessibilityHint="My Saved Feeds" - accessibilityLabel="Opens screen with all saved feeds" - onPress={onPressSavedFeeds}> - <View style={[styles.iconContainer, pal.btn]}> - <HashtagIcon style={pal.text} size={18} strokeWidth={3} /> - </View> - <Text type="lg" style={pal.text}> - My Saved Feeds - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="languageSettingsBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressLanguageSettings} - accessibilityRole="button" - accessibilityHint="Language settings" - accessibilityLabel="Opens configurable language settings"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="language" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Languages - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="moderationBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={ - isSwitching ? undefined : () => navigation.navigate('Moderation') - } - accessibilityRole="button" - accessibilityHint="" - accessibilityLabel="Opens moderation settings"> - <View style={[styles.iconContainer, pal.btn]}> - <HandIcon style={pal.text} size={18} strokeWidth={6} /> - </View> - <Text type="lg" style={pal.text}> - Moderation - </Text> - </TouchableOpacity> - <View style={styles.spacer20} /> - - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Advanced + </TouchableOpacity> + <TouchableOpacity + testID="preferencesThreadsButton" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={openThreadsPreferences} + accessibilityRole="button" + accessibilityHint="" + accessibilityLabel={_(msg`Opens the threads preferences`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon={['far', 'comments']} + style={pal.text as FontAwesomeIconStyle} + size={18} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Thread Preferences</Trans> </Text> - <TouchableOpacity - testID="appPasswordBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={onPressAppPasswords} - accessibilityRole="button" - accessibilityHint="Open app password settings" - accessibilityLabel="Opens the app password settings page"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="lock" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - App passwords - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="changeHandleBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressChangeHandle} - accessibilityRole="button" - accessibilityLabel="Change handle" - accessibilityHint="Choose a new Bluesky username or create"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="at" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text} numberOfLines={1}> - Change handle - </Text> - </TouchableOpacity> - <View style={styles.spacer20} /> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Danger Zone + </TouchableOpacity> + <TouchableOpacity + testID="savedFeedsBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + accessibilityHint="My Saved Feeds" + accessibilityLabel={_(msg`Opens screen with all saved feeds`)} + onPress={onPressSavedFeeds}> + <View style={[styles.iconContainer, pal.btn]}> + <HashtagIcon style={pal.text} size={18} strokeWidth={3} /> + </View> + <Text type="lg" style={pal.text}> + <Trans>My Saved Feeds</Trans> </Text> - <TouchableOpacity - style={[pal.view, styles.linkCard]} - onPress={onPressDeleteAccount} - accessible={true} - accessibilityRole="button" - accessibilityLabel="Delete account" - accessibilityHint="Opens modal for account deletion confirmation. Requires email code."> - <View style={[styles.iconContainer, dangerBg]}> - <FontAwesomeIcon - icon={['far', 'trash-can']} - style={dangerText as FontAwesomeIconStyle} - size={18} - /> - </View> - <Text type="lg" style={dangerText}> - Delete my account… - </Text> - </TouchableOpacity> - <View style={styles.spacer20} /> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Developer Tools + </TouchableOpacity> + <TouchableOpacity + testID="languageSettingsBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={isSwitchingAccounts ? undefined : onPressLanguageSettings} + accessibilityRole="button" + accessibilityHint="Language settings" + accessibilityLabel={_(msg`Opens configurable language settings`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="language" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Languages</Trans> </Text> - <TouchableOpacity - style={[pal.view, styles.linkCardNoIcon]} - onPress={onPressSystemLog} - accessibilityRole="button" - accessibilityHint="Open system log" - accessibilityLabel="Opens the system log page"> - <Text type="lg" style={pal.text}> - System log - </Text> - </TouchableOpacity> - {__DEV__ ? ( - <ToggleButton - type="default-light" - label="Experiment: Use AppView Proxy" - isSelected={debugHeaderEnabled} - onPress={toggleDebugHeader} + </TouchableOpacity> + <TouchableOpacity + testID="moderationBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={ + isSwitchingAccounts + ? undefined + : () => navigation.navigate('Moderation') + } + accessibilityRole="button" + accessibilityHint="" + accessibilityLabel={_(msg`Opens moderation settings`)}> + <View style={[styles.iconContainer, pal.btn]}> + <HandIcon style={pal.text} size={18} strokeWidth={6} /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Moderation</Trans> + </Text> + </TouchableOpacity> + <View style={styles.spacer20} /> + + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Advanced</Trans> + </Text> + <TouchableOpacity + testID="appPasswordBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={onPressAppPasswords} + accessibilityRole="button" + accessibilityHint="Open app password settings" + accessibilityLabel={_(msg`Opens the app password settings page`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="lock" + style={pal.text as FontAwesomeIconStyle} /> - ) : null} - {__DEV__ ? ( - <> - <TouchableOpacity - style={[pal.view, styles.linkCardNoIcon]} - onPress={onPressStorybook} - accessibilityRole="button" - accessibilityHint="Open storybook page" - accessibilityLabel="Opens the storybook page"> - <Text type="lg" style={pal.text}> - Storybook - </Text> - </TouchableOpacity> - <TouchableOpacity - style={[pal.view, styles.linkCardNoIcon]} - onPress={onPressResetPreferences} - accessibilityRole="button" - accessibilityHint="Reset preferences" - accessibilityLabel="Resets the preferences state"> - <Text type="lg" style={pal.text}> - Reset preferences state - </Text> - </TouchableOpacity> - <TouchableOpacity - style={[pal.view, styles.linkCardNoIcon]} - onPress={onPressResetOnboarding} - accessibilityRole="button" - accessibilityHint="Reset onboarding" - accessibilityLabel="Resets the onboarding state"> - <Text type="lg" style={pal.text}> - Reset onboarding state - </Text> - </TouchableOpacity> - </> - ) : null} - <View style={[styles.footer]}> + </View> + <Text type="lg" style={pal.text}> + <Trans>App passwords</Trans> + </Text> + </TouchableOpacity> + <TouchableOpacity + testID="changeHandleBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={isSwitchingAccounts ? undefined : onPressChangeHandle} + accessibilityRole="button" + accessibilityLabel={_(msg`Change handle`)} + accessibilityHint="Choose a new Bluesky username or create"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="at" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text} numberOfLines={1}> + <Trans>Change handle</Trans> + </Text> + </TouchableOpacity> + <View style={styles.spacer20} /> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Danger Zone</Trans> + </Text> + <TouchableOpacity + style={[pal.view, styles.linkCard]} + onPress={onPressDeleteAccount} + accessible={true} + accessibilityRole="button" + accessibilityLabel={_(msg`Delete account`)} + accessibilityHint="Opens modal for account deletion confirmation. Requires email code."> + <View style={[styles.iconContainer, dangerBg]}> + <FontAwesomeIcon + icon={['far', 'trash-can']} + style={dangerText as FontAwesomeIconStyle} + size={18} + /> + </View> + <Text type="lg" style={dangerText}> + <Trans>Delete my account…</Trans> + </Text> + </TouchableOpacity> + <View style={styles.spacer20} /> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Developer Tools</Trans> + </Text> + <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressSystemLog} + accessibilityRole="button" + accessibilityHint="Open system log" + accessibilityLabel={_(msg`Opens the system log page`)}> + <Text type="lg" style={pal.text}> + <Trans>System log</Trans> + </Text> + </TouchableOpacity> + {__DEV__ ? ( + <ToggleButton + type="default-light" + label="Experiment: Use AppView Proxy" + isSelected={debugHeaderEnabled} + onPress={toggleDebugHeader} + /> + ) : null} + {__DEV__ ? ( + <> <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressStorybook} accessibilityRole="button" - onPress={onPressBuildInfo}> - <Text type="sm" style={[styles.buildInfo, pal.textLight]}> - Build version {AppInfo.appVersion} {AppInfo.updateChannel} + accessibilityHint="Open storybook page" + accessibilityLabel={_(msg`Opens the storybook page`)}> + <Text type="lg" style={pal.text}> + <Trans>Storybook</Trans> </Text> </TouchableOpacity> - <Text type="sm" style={[pal.textLight]}> - · - </Text> <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressResetPreferences} accessibilityRole="button" - onPress={onPressStatusPage}> - <Text type="sm" style={[styles.buildInfo, pal.textLight]}> - Status page + accessibilityHint="Reset preferences" + accessibilityLabel={_(msg`Resets the preferences state`)}> + <Text type="lg" style={pal.text}> + <Trans>Reset preferences state</Trans> </Text> </TouchableOpacity> - </View> - <View style={s.footerSpacer} /> - </ScrollView> - </View> - ) - }), -) - -const EmailConfirmationNotice = observer( - function EmailConfirmationNoticeImpl() { - const pal = usePalette('default') - const palInverted = usePalette('inverted') - const store = useStores() - const {isMobile} = useWebMediaQueries() - - if (!store.session.emailNeedsConfirmation) { - return null - } - - return ( - <View style={{marginBottom: 20}}> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Verify email - </Text> - <View - style={[ - { - paddingVertical: isMobile ? 12 : 0, - paddingHorizontal: 18, - }, - pal.view, - ]}> - <View style={{flexDirection: 'row', marginBottom: 8}}> - <Pressable - style={[ - palInverted.view, - { - flexDirection: 'row', - gap: 6, - borderRadius: 6, - paddingHorizontal: 12, - paddingVertical: 10, - alignItems: 'center', - }, - isMobile && {flex: 1}, - ]} + <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressResetOnboarding} accessibilityRole="button" - accessibilityLabel="Verify my email" - accessibilityHint="" - onPress={() => store.shell.openModal({name: 'verify-email'})}> - <FontAwesomeIcon - icon="envelope" - color={palInverted.colors.text} - size={16} - /> - <Text type="button" style={palInverted.text}> - Verify My Email + accessibilityHint="Reset onboarding" + accessibilityLabel={_(msg`Resets the onboarding state`)}> + <Text type="lg" style={pal.text}> + <Trans>Reset onboarding state</Trans> </Text> - </Pressable> - </View> - <Text style={pal.textLight}> - Protect your account by verifying your email. + </TouchableOpacity> + <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={clearAllLegacyStorage} + accessibilityRole="button" + accessibilityHint="Clear all legacy storage data" + accessibilityLabel={_(msg`Clear all legacy storage data`)}> + <Text type="lg" style={pal.text}> + <Trans> + Clear all legacy storage data (restart after this) + </Trans> + </Text> + </TouchableOpacity> + <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={clearAllStorage} + accessibilityRole="button" + accessibilityHint="Clear all storage data" + accessibilityLabel={_(msg`Clear all storage data`)}> + <Text type="lg" style={pal.text}> + <Trans>Clear all storage data (restart after this)</Trans> + </Text> + </TouchableOpacity> + </> + ) : null} + <View style={[styles.footer]}> + <TouchableOpacity + accessibilityRole="button" + onPress={onPressBuildInfo}> + <Text type="sm" style={[styles.buildInfo, pal.textLight]}> + <Trans> + Build version {AppInfo.appVersion} {AppInfo.updateChannel} + </Trans> + </Text> + </TouchableOpacity> + <Text type="sm" style={[pal.textLight]}> + · </Text> + <TouchableOpacity + accessibilityRole="button" + onPress={onPressStatusPage}> + <Text type="sm" style={[styles.buildInfo, pal.textLight]}> + <Trans>Status page</Trans> + </Text> + </TouchableOpacity> + </View> + <View style={s.footerSpacer} /> + </ScrollView> + </View> + ) +} + +function EmailConfirmationNotice() { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const {_} = useLingui() + const {isMobile} = useWebMediaQueries() + const {openModal} = useModalControls() + + return ( + <View style={{marginBottom: 20}}> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Verify email</Trans> + </Text> + <View + style={[ + { + paddingVertical: isMobile ? 12 : 0, + paddingHorizontal: 18, + }, + pal.view, + ]}> + <View style={{flexDirection: 'row', marginBottom: 8}}> + <Pressable + style={[ + palInverted.view, + { + flexDirection: 'row', + gap: 6, + borderRadius: 6, + paddingHorizontal: 12, + paddingVertical: 10, + alignItems: 'center', + }, + isMobile && {flex: 1}, + ]} + accessibilityRole="button" + accessibilityLabel={_(msg`Verify my email`)} + accessibilityHint="" + onPress={() => openModal({name: 'verify-email'})}> + <FontAwesomeIcon + icon="envelope" + color={palInverted.colors.text} + size={16} + /> + <Text type="button" style={palInverted.text}> + <Trans>Verify My Email</Trans> + </Text> + </Pressable> </View> + <Text style={pal.textLight}> + <Trans>Protect your account by verifying your email.</Trans> + </Text> </View> - ) - }, -) + </View> + ) +} const styles = StyleSheet.create({ dimmed: { diff --git a/src/view/screens/Support.tsx b/src/view/screens/Support.tsx index 7106b4136..6856f6759 100644 --- a/src/view/screens/Support.tsx +++ b/src/view/screens/Support.tsx @@ -10,11 +10,14 @@ import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {HELP_DESK_URL} from 'lib/constants' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Support'> export const SupportScreen = (_props: Props) => { const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -24,19 +27,21 @@ export const SupportScreen = (_props: Props) => { return ( <View> - <ViewHeader title="Support" /> + <ViewHeader title={_(msg`Support`)} /> <CenteredView> <Text type="title-xl" style={[pal.text, s.p20, s.pb5]}> - Support + <Trans>Support</Trans> </Text> <Text style={[pal.text, s.p20]}> - The support form has been moved. If you need help, please - <TextLink - href={HELP_DESK_URL} - text=" click here" - style={pal.link} - />{' '} - or visit {HELP_DESK_URL} to get in touch with us. + <Trans> + The support form has been moved. If you need help, please + <TextLink + href={HELP_DESK_URL} + text=" click here" + style={pal.link} + />{' '} + or visit {HELP_DESK_URL} to get in touch with us. + </Trans> </Text> </CenteredView> </View> diff --git a/src/view/screens/TermsOfService.tsx b/src/view/screens/TermsOfService.tsx index b7a388b65..c20890e29 100644 --- a/src/view/screens/TermsOfService.tsx +++ b/src/view/screens/TermsOfService.tsx @@ -9,11 +9,14 @@ import {ScrollView} from 'view/com/util/Views' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'TermsOfService'> export const TermsOfServiceScreen = (_props: Props) => { const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -23,11 +26,11 @@ export const TermsOfServiceScreen = (_props: Props) => { return ( <View> - <ViewHeader title="Terms of Service" /> + <ViewHeader title={_(msg`Terms of Service`)} /> <ScrollView style={[s.hContentRegion, pal.view]}> <View style={[s.p20]}> <Text style={pal.text}> - The Terms of Service have been moved to{' '} + <Trans>The Terms of Service have been moved to</Trans>{' '} <TextLink style={pal.link} href="https://blueskyweb.xyz/support/tos" diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx index 219a594ed..d37ff4fb7 100644 --- a/src/view/shell/Composer.tsx +++ b/src/view/shell/Composer.tsx @@ -2,30 +2,21 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {Animated, Easing, Platform, StyleSheet, View} from 'react-native' import {ComposePost} from '../com/composer/Composer' -import {ComposerOpts} from 'state/models/ui/shell' +import {useComposerState} from 'state/shell/composer' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {usePalette} from 'lib/hooks/usePalette' export const Composer = observer(function ComposerImpl({ - active, winHeight, - replyTo, - onPost, - quote, - mention, }: { - active: boolean winHeight: number - replyTo?: ComposerOpts['replyTo'] - onPost?: ComposerOpts['onPost'] - quote?: ComposerOpts['quote'] - mention?: ComposerOpts['mention'] }) { + const state = useComposerState() const pal = usePalette('default') const initInterp = useAnimatedValue(0) useEffect(() => { - if (active) { + if (state) { Animated.timing(initInterp, { toValue: 1, duration: 300, @@ -35,7 +26,7 @@ export const Composer = observer(function ComposerImpl({ } else { initInterp.setValue(0) } - }, [initInterp, active]) + }, [initInterp, state]) const wrapperAnimStyle = { transform: [ { @@ -50,7 +41,7 @@ export const Composer = observer(function ComposerImpl({ // rendering // = - if (!active) { + if (!state) { return <View /> } @@ -60,10 +51,10 @@ export const Composer = observer(function ComposerImpl({ aria-modal accessibilityViewIsModal> <ComposePost - replyTo={replyTo} - onPost={onPost} - quote={quote} - mention={mention} + replyTo={state.replyTo} + onPost={state.onPost} + quote={state.quote} + mention={state.mention} /> </Animated.View> ) diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index c3ec37e57..73f9f540e 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -1,40 +1,35 @@ import React from 'react' -import {observer} from 'mobx-react-lite' import {StyleSheet, View} from 'react-native' +import Animated, {FadeIn, FadeInDown, FadeOut} from 'react-native-reanimated' import {ComposePost} from '../com/composer/Composer' -import {ComposerOpts} from 'state/models/ui/shell' +import {useComposerState} from 'state/shell/composer' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' const BOTTOM_BAR_HEIGHT = 61 -export const Composer = observer(function ComposerImpl({ - active, - replyTo, - quote, - onPost, - mention, -}: { - active: boolean - winHeight: number - replyTo?: ComposerOpts['replyTo'] - quote: ComposerOpts['quote'] - onPost?: ComposerOpts['onPost'] - mention?: ComposerOpts['mention'] -}) { +export function Composer({}: {winHeight: number}) { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() + const state = useComposerState() // rendering // = - if (!active) { + if (!state) { return <View /> } return ( - <View style={styles.mask} aria-modal accessibilityViewIsModal> - <View + <Animated.View + style={styles.mask} + aria-modal + accessibilityViewIsModal + entering={FadeIn.duration(100)} + exiting={FadeOut}> + <Animated.View + entering={FadeInDown.duration(150)} + exiting={FadeOut} style={[ styles.container, isMobile && styles.containerMobile, @@ -42,15 +37,15 @@ export const Composer = observer(function ComposerImpl({ pal.border, ]}> <ComposePost - replyTo={replyTo} - quote={quote} - onPost={onPost} - mention={mention} + replyTo={state.replyTo} + quote={state.quote} + onPost={state.onPost} + mention={state.mention} /> - </View> - </View> + </Animated.View> + </Animated.View> ) -}) +} const styles = StyleSheet.create({ mask: { diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 7f5e6c5e5..459a021c4 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -10,14 +10,13 @@ import { ViewStyle, } from 'react-native' import {useNavigation, StackActions} from '@react-navigation/native' -import {observer} from 'mobx-react-lite' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {useQueryClient} from '@tanstack/react-query' import {s, colors} from 'lib/styles' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants' -import {useStores} from 'state/index' import { HomeIcon, HomeIconSolid, @@ -42,20 +41,81 @@ import {getTabState, TabState} from 'lib/routes/helpers' import {NavigationProp} from 'lib/routes/types' import {useNavigationTabState} from 'lib/hooks/useNavigationTabState' import {isWeb} from 'platform/detection' -import {formatCount, formatCountShortOnly} from 'view/com/util/numeric/format' +import {formatCountShortOnly} from 'view/com/util/numeric/format' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useSetDrawerOpen} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {useSession, SessionAccount} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' +import {useUnreadNotifications} from '#/state/queries/notifications/unread' +import {emitSoftReset} from '#/state/events' +import {useInviteCodesQuery} from '#/state/queries/invites' +import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed' +import {NavSignupCard} from '#/view/shell/NavSignupCard' +import {truncateAndInvalidate} from '#/state/queries/util' -export const DrawerContent = observer(function DrawerContentImpl() { +export function DrawerProfileCard({ + account, + onPressProfile, +}: { + account: SessionAccount + onPressProfile: () => void +}) { + const {_} = useLingui() + const pal = usePalette('default') + const {data: profile} = useProfileQuery({did: account.did}) + + return ( + <TouchableOpacity + testID="profileCardButton" + accessibilityLabel={_(msg`Profile`)} + accessibilityHint="Navigates to your profile" + onPress={onPressProfile}> + <UserAvatar + size={80} + avatar={profile?.avatar} + // See https://github.com/bluesky-social/social-app/pull/1801: + usePlainRNImage={true} + /> + <Text + type="title-lg" + style={[pal.text, s.bold, styles.profileCardDisplayName]} + numberOfLines={1}> + {profile?.displayName || account.handle} + </Text> + <Text + type="2xl" + style={[pal.textLight, styles.profileCardHandle]} + numberOfLines={1}> + @{account.handle} + </Text> + <Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}> + <Text type="xl-medium" style={pal.text}> + {formatCountShortOnly(profile?.followersCount ?? 0)} + </Text>{' '} + {pluralize(profile?.followersCount || 0, 'follower')} ·{' '} + <Text type="xl-medium" style={pal.text}> + {formatCountShortOnly(profile?.followsCount ?? 0)} + </Text>{' '} + following + </Text> + </TouchableOpacity> + ) +} + +export function DrawerContent() { const theme = useTheme() const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const queryClient = useQueryClient() const setDrawerOpen = useSetDrawerOpen() const navigation = useNavigation<NavigationProp>() const {track} = useAnalytics() const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = useNavigationTabState() - - const {notifications} = store.me + const {hasSession, currentAccount} = useSession() + const numUnreadNotifications = useUnreadNotifications() // events // = @@ -68,7 +128,7 @@ export const DrawerContent = observer(function DrawerContentImpl() { if (isWeb) { // hack because we have flat navigator for web and MyProfile does not exist on the web navigator -ansh if (tab === 'MyProfile') { - navigation.navigate('Profile', {name: store.me.handle}) + navigation.navigate('Profile', {name: currentAccount!.handle}) } else { // @ts-ignore must be Home, Search, Notifications, or MyProfile navigation.navigate(tab) @@ -76,16 +136,20 @@ export const DrawerContent = observer(function DrawerContentImpl() { } else { const tabState = getTabState(state, tab) if (tabState === TabState.InsideAtRoot) { - store.emitScreenSoftReset() + emitSoftReset() } else if (tabState === TabState.Inside) { navigation.dispatch(StackActions.popToTop()) } else { + if (tab === 'Notifications') { + // fetch new notifs on view + truncateAndInvalidate(queryClient, NOTIFS_RQKEY()) + } // @ts-ignore must be Home, Search, Notifications, or MyProfile navigation.navigate(`${tab}Tab`) } } }, - [store, track, navigation, setDrawerOpen], + [track, navigation, setDrawerOpen, currentAccount, queryClient], ) const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab]) @@ -131,11 +195,11 @@ export const DrawerContent = observer(function DrawerContentImpl() { track('Menu:FeedbackClicked') Linking.openURL( FEEDBACK_FORM_URL({ - email: store.session.currentSession?.email, - handle: store.session.currentSession?.handle, + email: currentAccount?.email, + handle: currentAccount?.handle, }), ) - }, [track, store.session.currentSession]) + }, [track, currentAccount]) const onPressHelp = React.useCallback(() => { track('Menu:HelpClicked') @@ -154,48 +218,20 @@ export const DrawerContent = observer(function DrawerContentImpl() { ]}> <SafeAreaView style={s.flex1}> <ScrollView style={styles.main}> - <View style={{}}> - <TouchableOpacity - testID="profileCardButton" - accessibilityLabel="Profile" - accessibilityHint="Navigates to your profile" - onPress={onPressProfile}> - <UserAvatar - size={80} - avatar={store.me.avatar} - // See https://github.com/bluesky-social/social-app/pull/1801: - usePlainRNImage={true} + {hasSession && currentAccount ? ( + <View style={{}}> + <DrawerProfileCard + account={currentAccount} + onPressProfile={onPressProfile} /> - <Text - type="title-lg" - style={[pal.text, s.bold, styles.profileCardDisplayName]} - numberOfLines={1}> - {store.me.displayName || store.me.handle} - </Text> - <Text - type="2xl" - style={[pal.textLight, styles.profileCardHandle]} - numberOfLines={1}> - @{store.me.handle} - </Text> - <Text - type="xl" - style={[pal.textLight, styles.profileCardFollowers]}> - <Text type="xl-medium" style={pal.text}> - {formatCountShortOnly(store.me.followersCount ?? 0)} - </Text>{' '} - {pluralize(store.me.followersCount || 0, 'follower')} ·{' '} - <Text type="xl-medium" style={pal.text}> - {formatCountShortOnly(store.me.followsCount ?? 0)} - </Text>{' '} - following - </Text> - </TouchableOpacity> - </View> + </View> + ) : ( + <NavSignupCard /> + )} - <InviteCodes style={{paddingLeft: 0}} /> + {hasSession && <InviteCodes style={{paddingLeft: 0}} />} - <View style={{height: 10}} /> + {hasSession && <View style={{height: 10}} />} <MenuItem icon={ @@ -213,8 +249,8 @@ export const DrawerContent = observer(function DrawerContentImpl() { /> ) } - label="Search" - accessibilityLabel="Search" + label={_(msg`Search`)} + accessibilityLabel={_(msg`Search`)} accessibilityHint="" bold={isAtSearch} onPress={onPressSearch} @@ -235,39 +271,43 @@ export const DrawerContent = observer(function DrawerContentImpl() { /> ) } - label="Home" - accessibilityLabel="Home" + label={_(msg`Home`)} + accessibilityLabel={_(msg`Home`)} accessibilityHint="" bold={isAtHome} onPress={onPressHome} /> - <MenuItem - icon={ - isAtNotifications ? ( - <BellIconSolid - style={pal.text as StyleProp<ViewStyle>} - size="24" - strokeWidth={1.7} - /> - ) : ( - <BellIcon - style={pal.text as StyleProp<ViewStyle>} - size="24" - strokeWidth={1.7} - /> - ) - } - label="Notifications" - accessibilityLabel="Notifications" - accessibilityHint={ - notifications.unreadCountLabel === '' - ? '' - : `${notifications.unreadCountLabel} unread` - } - count={notifications.unreadCountLabel} - bold={isAtNotifications} - onPress={onPressNotifications} - /> + + {hasSession && ( + <MenuItem + icon={ + isAtNotifications ? ( + <BellIconSolid + style={pal.text as StyleProp<ViewStyle>} + size="24" + strokeWidth={1.7} + /> + ) : ( + <BellIcon + style={pal.text as StyleProp<ViewStyle>} + size="24" + strokeWidth={1.7} + /> + ) + } + label={_(msg`Notifications`)} + accessibilityLabel={_(msg`Notifications`)} + accessibilityHint={ + numUnreadNotifications === '' + ? '' + : `${numUnreadNotifications} unread` + } + count={numUnreadNotifications} + bold={isAtNotifications} + onPress={onPressNotifications} + /> + )} + <MenuItem icon={ isAtFeeds ? ( @@ -284,68 +324,74 @@ export const DrawerContent = observer(function DrawerContentImpl() { /> ) } - label="Feeds" - accessibilityLabel="Feeds" + label={_(msg`Feeds`)} + accessibilityLabel={_(msg`Feeds`)} accessibilityHint="" bold={isAtFeeds} onPress={onPressMyFeeds} /> - <MenuItem - icon={<ListIcon strokeWidth={2} style={pal.text} size={26} />} - label="Lists" - accessibilityLabel="Lists" - accessibilityHint="" - onPress={onPressLists} - /> - <MenuItem - icon={<HandIcon strokeWidth={5} style={pal.text} size={24} />} - label="Moderation" - accessibilityLabel="Moderation" - accessibilityHint="" - onPress={onPressModeration} - /> - <MenuItem - icon={ - isAtMyProfile ? ( - <UserIconSolid - style={pal.text as StyleProp<ViewStyle>} - size="26" - strokeWidth={1.5} - /> - ) : ( - <UserIcon - style={pal.text as StyleProp<ViewStyle>} - size="26" - strokeWidth={1.5} - /> - ) - } - label="Profile" - accessibilityLabel="Profile" - accessibilityHint="" - onPress={onPressProfile} - /> - <MenuItem - icon={ - <CogIcon - style={pal.text as StyleProp<ViewStyle>} - size="26" - strokeWidth={1.75} + + {hasSession && ( + <> + <MenuItem + icon={<ListIcon strokeWidth={2} style={pal.text} size={26} />} + label={_(msg`Lists`)} + accessibilityLabel={_(msg`Lists`)} + accessibilityHint="" + onPress={onPressLists} /> - } - label="Settings" - accessibilityLabel="Settings" - accessibilityHint="" - onPress={onPressSettings} - /> + <MenuItem + icon={<HandIcon strokeWidth={5} style={pal.text} size={24} />} + label={_(msg`Moderation`)} + accessibilityLabel={_(msg`Moderation`)} + accessibilityHint="" + onPress={onPressModeration} + /> + <MenuItem + icon={ + isAtMyProfile ? ( + <UserIconSolid + style={pal.text as StyleProp<ViewStyle>} + size="26" + strokeWidth={1.5} + /> + ) : ( + <UserIcon + style={pal.text as StyleProp<ViewStyle>} + size="26" + strokeWidth={1.5} + /> + ) + } + label={_(msg`Profile`)} + accessibilityLabel={_(msg`Profile`)} + accessibilityHint="" + onPress={onPressProfile} + /> + <MenuItem + icon={ + <CogIcon + style={pal.text as StyleProp<ViewStyle>} + size="26" + strokeWidth={1.75} + /> + } + label={_(msg`Settings`)} + accessibilityLabel={_(msg`Settings`)} + accessibilityHint="" + onPress={onPressSettings} + /> + </> + )} <View style={styles.smallSpacer} /> <View style={styles.smallSpacer} /> </ScrollView> + <View style={styles.footer}> <TouchableOpacity accessibilityRole="link" - accessibilityLabel="Send feedback" + accessibilityLabel={_(msg`Send feedback`)} accessibilityHint="" onPress={onPressFeedback} style={[ @@ -361,24 +407,24 @@ export const DrawerContent = observer(function DrawerContentImpl() { icon={['far', 'message']} /> <Text type="lg-medium" style={[pal.link, s.pl10]}> - Feedback + <Trans>Feedback</Trans> </Text> </TouchableOpacity> <TouchableOpacity accessibilityRole="link" - accessibilityLabel="Send feedback" + accessibilityLabel={_(msg`Send feedback`)} accessibilityHint="" onPress={onPressHelp} style={[styles.footerBtn]}> <Text type="lg-medium" style={[pal.link, s.pl10]}> - Help + <Trans>Help</Trans> </Text> </TouchableOpacity> </View> </SafeAreaView> </View> ) -}) +} interface MenuItemProps extends ComponentProps<typeof TouchableOpacity> { icon: JSX.Element @@ -432,50 +478,54 @@ function MenuItem({ ) } -const InviteCodes = observer(function InviteCodesImpl({ - style, -}: { - style?: StyleProp<ViewStyle> -}) { +function InviteCodes({style}: {style?: StyleProp<ViewStyle>}) { const {track} = useAnalytics() - const store = useStores() const setDrawerOpen = useSetDrawerOpen() const pal = usePalette('default') - const {invitesAvailable} = store.me + const {data: invites} = useInviteCodesQuery() + const invitesAvailable = invites?.available?.length ?? 0 + const {openModal} = useModalControls() + const {_} = useLingui() + const onPress = React.useCallback(() => { track('Menu:ItemClicked', {url: '#invite-codes'}) setDrawerOpen(false) - store.shell.openModal({name: 'invite-codes'}) - }, [store, track, setDrawerOpen]) + openModal({name: 'invite-codes'}) + }, [openModal, track, setDrawerOpen]) + return ( <TouchableOpacity testID="menuItemInviteCodes" style={[styles.inviteCodes, style]} onPress={onPress} accessibilityRole="button" - accessibilityLabel={ - invitesAvailable === 1 - ? 'Invite codes: 1 available' - : `Invite codes: ${invitesAvailable} available` - } - accessibilityHint="Opens list of invite codes"> + accessibilityLabel={_(msg`Invite codes: ${invitesAvailable} available`)} + accessibilityHint={_(msg`Opens list of invite codes`)} + disabled={invites?.disabled}> <FontAwesomeIcon icon="ticket" style={[ styles.inviteCodesIcon, - store.me.invitesAvailable > 0 ? pal.link : pal.textLight, + invitesAvailable > 0 ? pal.link : pal.textLight, ]} size={18} /> <Text type="lg-medium" - style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> - {formatCount(store.me.invitesAvailable)} invite{' '} - {pluralize(store.me.invitesAvailable, 'code')} + style={invitesAvailable > 0 ? pal.link : pal.textLight}> + {invites?.disabled ? ( + <Trans> + Your invite codes are hidden when logged in using an App Password + </Trans> + ) : invitesAvailable === 1 ? ( + <Trans>{invitesAvailable} invite code available</Trans> + ) : ( + <Trans>{invitesAvailable} invite codes available</Trans> + )} </Text> </TouchableOpacity> ) -}) +} const styles = StyleSheet.create({ view: { @@ -548,10 +598,11 @@ const styles = StyleSheet.create({ paddingLeft: 22, paddingVertical: 8, flexDirection: 'row', - alignItems: 'center', }, inviteCodesIcon: { marginRight: 6, + flexShrink: 0, + marginTop: 2, }, footer: { diff --git a/src/view/shell/NavSignupCard.tsx b/src/view/shell/NavSignupCard.tsx new file mode 100644 index 000000000..7026dd2a6 --- /dev/null +++ b/src/view/shell/NavSignupCard.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {DefaultAvatar} from '#/view/com/util/UserAvatar' +import {Text} from '#/view/com/util/text/Text' +import {Button} from '#/view/com/util/forms/Button' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import {useCloseAllActiveElements} from '#/state/util' + +export function NavSignupCard() { + const {_} = useLingui() + const pal = usePalette('default') + const {setShowLoggedOut} = useLoggedOutViewControls() + const closeAllActiveElements = useCloseAllActiveElements() + + const showLoggedOut = React.useCallback(() => { + closeAllActiveElements() + setShowLoggedOut(true) + }, [setShowLoggedOut, closeAllActiveElements]) + + return ( + <View + style={{ + alignItems: 'flex-start', + paddingTop: 6, + marginBottom: 24, + }}> + <DefaultAvatar type="user" size={48} /> + + <View style={{paddingTop: 12}}> + <Text type="md" style={[pal.text, s.bold]}> + <Trans>Sign up or sign in to join the conversation</Trans> + </Text> + </View> + + <View style={{flexDirection: 'row', paddingTop: 12, gap: 8}}> + <Button + onPress={showLoggedOut} + accessibilityHint={_(msg`Sign up`)} + accessibilityLabel={_(msg`Sign up`)}> + <Text type="md" style={[{color: 'white'}, s.bold]}> + <Trans>Sign up</Trans> + </Text> + </Button> + <Button + type="default" + onPress={showLoggedOut} + accessibilityHint={_(msg`Sign in`)} + accessibilityLabel={_(msg`Sign in`)}> + <Text type="md" style={[pal.text, s.bold]}> + Sign in + </Text> + </Button> + </View> + </View> + ) +} diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index d360ceead..746b4d123 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -1,12 +1,11 @@ import React, {ComponentProps} from 'react' import {GestureResponderEvent, TouchableOpacity, View} from 'react-native' import Animated from 'react-native-reanimated' +import {useQueryClient} from '@tanstack/react-query' import {StackActions} from '@react-navigation/native' import {BottomTabBarProps} from '@react-navigation/bottom-tabs' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {observer} from 'mobx-react-lite' import {Text} from 'view/com/util/text/Text' -import {useStores} from 'state/index' import {useAnalytics} from 'lib/analytics/analytics' import {clamp} from 'lib/numbers' import { @@ -24,21 +23,33 @@ import {styles} from './BottomBarStyles' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {useNavigationTabState} from 'lib/hooks/useNavigationTabState' import {UserAvatar} from 'view/com/util/UserAvatar' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {useModalControls} from '#/state/modals' +import {useShellLayout} from '#/state/shell/shell-layout' +import {useUnreadNotifications} from '#/state/queries/notifications/unread' +import {emitSoftReset} from '#/state/events' +import {useSession} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' +import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed' +import {truncateAndInvalidate} from '#/state/queries/util' type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds' -export const BottomBar = observer(function BottomBarImpl({ - navigation, -}: BottomTabBarProps) { - const store = useStores() +export function BottomBar({navigation}: BottomTabBarProps) { + const {openModal} = useModalControls() + const {hasSession, currentAccount} = useSession() const pal = usePalette('default') + const {_} = useLingui() + const queryClient = useQueryClient() const safeAreaInsets = useSafeAreaInsets() const {track} = useAnalytics() + const {footerHeight} = useShellLayout() const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = useNavigationTabState() - - const {minimalShellMode, footerMinimalShellTransform} = useMinimalShellMode() - const {notifications} = store.me + const numUnreadNotifications = useUnreadNotifications() + const {footerMinimalShellTransform} = useMinimalShellMode() + const {data: profile} = useProfileQuery({did: currentAccount?.did}) const onPressTab = React.useCallback( (tab: TabOptions) => { @@ -46,14 +57,18 @@ export const BottomBar = observer(function BottomBarImpl({ const state = navigation.getState() const tabState = getTabState(state, tab) if (tabState === TabState.InsideAtRoot) { - store.emitScreenSoftReset() + emitSoftReset() } else if (tabState === TabState.Inside) { navigation.dispatch(StackActions.popToTop()) } else { + if (tab === 'Notifications') { + // fetch new notifs on view + truncateAndInvalidate(queryClient, NOTIFS_RQKEY()) + } navigation.navigate(`${tab}Tab`) } }, - [store, track, navigation], + [track, navigation, queryClient], ) const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab]) const onPressSearch = React.useCallback( @@ -72,8 +87,8 @@ export const BottomBar = observer(function BottomBarImpl({ onPressTab('MyProfile') }, [onPressTab]) const onLongPressProfile = React.useCallback(() => { - store.shell.openModal({name: 'switch-account'}) - }, [store]) + openModal({name: 'switch-account'}) + }, [openModal]) return ( <Animated.View @@ -83,8 +98,10 @@ export const BottomBar = observer(function BottomBarImpl({ pal.border, {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)}, footerMinimalShellTransform, - minimalShellMode && styles.disabled, - ]}> + ]} + onLayout={e => { + footerHeight.value = e.nativeEvent.layout.height + }}> <Btn testID="bottomBarHomeBtn" icon={ @@ -104,7 +121,7 @@ export const BottomBar = observer(function BottomBarImpl({ } onPress={onPressHome} accessibilityRole="tab" - accessibilityLabel="Home" + accessibilityLabel={_(msg`Home`)} accessibilityHint="" /> <Btn @@ -126,7 +143,7 @@ export const BottomBar = observer(function BottomBarImpl({ } onPress={onPressSearch} accessibilityRole="search" - accessibilityLabel="Search" + accessibilityLabel={_(msg`Search`)} accessibilityHint="" /> <Btn @@ -148,78 +165,83 @@ export const BottomBar = observer(function BottomBarImpl({ } onPress={onPressFeeds} accessibilityRole="tab" - accessibilityLabel="Feeds" + accessibilityLabel={_(msg`Feeds`)} accessibilityHint="" /> - <Btn - testID="bottomBarNotificationsBtn" - icon={ - isAtNotifications ? ( - <BellIconSolid - size={24} - strokeWidth={1.9} - style={[styles.ctrlIcon, pal.text, styles.bellIcon]} - /> - ) : ( - <BellIcon - size={24} - strokeWidth={1.9} - style={[styles.ctrlIcon, pal.text, styles.bellIcon]} - /> - ) - } - onPress={onPressNotifications} - notificationCount={notifications.unreadCountLabel} - accessible={true} - accessibilityRole="tab" - accessibilityLabel="Notifications" - accessibilityHint={ - notifications.unreadCountLabel === '' - ? '' - : `${notifications.unreadCountLabel} unread` - } - /> - <Btn - testID="bottomBarProfileBtn" - icon={ - <View style={styles.ctrlIconSizingWrapper}> - {isAtMyProfile ? ( - <View - style={[ - styles.ctrlIcon, - pal.text, - styles.profileIcon, - styles.onProfile, - {borderColor: pal.text.color}, - ]}> - <UserAvatar - avatar={store.me.avatar} - size={27} - // See https://github.com/bluesky-social/social-app/pull/1801: - usePlainRNImage={true} + + {hasSession && ( + <> + <Btn + testID="bottomBarNotificationsBtn" + icon={ + isAtNotifications ? ( + <BellIconSolid + size={24} + strokeWidth={1.9} + style={[styles.ctrlIcon, pal.text, styles.bellIcon]} /> - </View> - ) : ( - <View style={[styles.ctrlIcon, pal.text, styles.profileIcon]}> - <UserAvatar - avatar={store.me.avatar} - size={28} - // See https://github.com/bluesky-social/social-app/pull/1801: - usePlainRNImage={true} + ) : ( + <BellIcon + size={24} + strokeWidth={1.9} + style={[styles.ctrlIcon, pal.text, styles.bellIcon]} /> + ) + } + onPress={onPressNotifications} + notificationCount={numUnreadNotifications} + accessible={true} + accessibilityRole="tab" + accessibilityLabel={_(msg`Notifications`)} + accessibilityHint={ + numUnreadNotifications === '' + ? '' + : `${numUnreadNotifications} unread` + } + /> + <Btn + testID="bottomBarProfileBtn" + icon={ + <View style={styles.ctrlIconSizingWrapper}> + {isAtMyProfile ? ( + <View + style={[ + styles.ctrlIcon, + pal.text, + styles.profileIcon, + styles.onProfile, + {borderColor: pal.text.color}, + ]}> + <UserAvatar + avatar={profile?.avatar} + size={27} + // See https://github.com/bluesky-social/social-app/pull/1801: + usePlainRNImage={true} + /> + </View> + ) : ( + <View style={[styles.ctrlIcon, pal.text, styles.profileIcon]}> + <UserAvatar + avatar={profile?.avatar} + size={28} + // See https://github.com/bluesky-social/social-app/pull/1801: + usePlainRNImage={true} + /> + </View> + )} </View> - )} - </View> - } - onPress={onPressProfile} - onLongPress={onLongPressProfile} - accessibilityRole="tab" - accessibilityLabel="Profile" - accessibilityHint="" - /> + } + onPress={onPressProfile} + onLongPress={onLongPressProfile} + accessibilityRole="tab" + accessibilityLabel={_(msg`Profile`)} + accessibilityHint="" + /> + </> + )} </Animated.View> ) -}) +} interface BtnProps extends Pick< diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx index c175ed848..ae9381440 100644 --- a/src/view/shell/bottom-bar/BottomBarStyles.tsx +++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx @@ -65,7 +65,4 @@ export const styles = StyleSheet.create({ borderWidth: 1, borderRadius: 100, }, - disabled: { - pointerEvents: 'none', - }, }) diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index ebcc527a1..3a60bd3b1 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -1,6 +1,4 @@ import React from 'react' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useNavigationState} from '@react-navigation/native' import Animated from 'react-native-reanimated' @@ -23,9 +21,10 @@ import {Link} from 'view/com/util/Link' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {makeProfileLink} from 'lib/routes/links' import {CommonNavigatorParams} from 'lib/routes/types' +import {useSession} from '#/state/session' -export const BottomBarWeb = observer(function BottomBarWebImpl() { - const store = useStores() +export function BottomBarWeb() { + const {hasSession, currentAccount} = useSession() const pal = usePalette('default') const safeAreaInsets = useSafeAreaInsets() const {footerMinimalShellTransform} = useMinimalShellMode() @@ -76,55 +75,69 @@ export const BottomBarWeb = observer(function BottomBarWebImpl() { ) }} </NavItem> - <NavItem routeName="Notifications" href="/notifications"> - {({isActive}) => { - const Icon = isActive ? BellIconSolid : BellIcon - return ( - <Icon - size={24} - strokeWidth={1.9} - style={[styles.ctrlIcon, pal.text, styles.bellIcon]} - /> - ) - }} - </NavItem> - <NavItem routeName="Profile" href={makeProfileLink(store.me)}> - {({isActive}) => { - const Icon = isActive ? UserIconSolid : UserIcon - return ( - <Icon - size={28} - strokeWidth={1.5} - style={[styles.ctrlIcon, pal.text, styles.profileIcon]} - /> - ) - }} - </NavItem> + + {hasSession && ( + <> + <NavItem routeName="Notifications" href="/notifications"> + {({isActive}) => { + const Icon = isActive ? BellIconSolid : BellIcon + return ( + <Icon + size={24} + strokeWidth={1.9} + style={[styles.ctrlIcon, pal.text, styles.bellIcon]} + /> + ) + }} + </NavItem> + <NavItem + routeName="Profile" + href={ + currentAccount + ? makeProfileLink({ + did: currentAccount.did, + handle: currentAccount.handle, + }) + : '/' + }> + {({isActive}) => { + const Icon = isActive ? UserIconSolid : UserIcon + return ( + <Icon + size={28} + strokeWidth={1.5} + style={[styles.ctrlIcon, pal.text, styles.profileIcon]} + /> + ) + }} + </NavItem> + </> + )} </Animated.View> ) -}) +} const NavItem: React.FC<{ children: (props: {isActive: boolean}) => React.ReactChild href: string routeName: string }> = ({children, href, routeName}) => { + const {currentAccount} = useSession() const currentRoute = useNavigationState(state => { if (!state) { return {name: 'Home'} } return getCurrentRoute(state) }) - const store = useStores() const isActive = currentRoute.name === 'Profile' ? isTab(currentRoute.name, routeName) && (currentRoute.params as CommonNavigatorParams['Profile']).name === - store.me.handle + currentAccount?.handle : isTab(currentRoute.name, routeName) return ( - <Link href={href} style={styles.ctrl}> + <Link href={href} style={styles.ctrl} navigationAction="navigate"> {children({isActive})} </Link> ) diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx new file mode 100644 index 000000000..c7b5d1d2e --- /dev/null +++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx @@ -0,0 +1,150 @@ +import * as React from 'react' +import {View} from 'react-native' + +// Based on @react-navigation/native-stack/src/createNativeStackNavigator.ts +// MIT License +// Copyright (c) 2017 React Navigation Contributors + +import { + createNavigatorFactory, + EventArg, + ParamListBase, + StackActionHelpers, + StackActions, + StackNavigationState, + StackRouter, + StackRouterOptions, + useNavigationBuilder, +} from '@react-navigation/native' +import type { + NativeStackNavigationEventMap, + NativeStackNavigationOptions, +} from '@react-navigation/native-stack' +import type {NativeStackNavigatorProps} from '@react-navigation/native-stack/src/types' +import {NativeStackView} from '@react-navigation/native-stack' + +import {BottomBarWeb} from './bottom-bar/BottomBarWeb' +import {DesktopLeftNav} from './desktop/LeftNav' +import {DesktopRightNav} from './desktop/RightNav' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {useOnboardingState} from '#/state/shell' +import { + useLoggedOutView, + useLoggedOutViewControls, +} from '#/state/shell/logged-out' +import {useSession} from '#/state/session' +import {isWeb} from 'platform/detection' +import {LoggedOut} from '../com/auth/LoggedOut' +import {Onboarding} from '../com/auth/Onboarding' + +type NativeStackNavigationOptionsWithAuth = NativeStackNavigationOptions & { + requireAuth?: boolean +} + +function NativeStackNavigator({ + id, + initialRouteName, + children, + screenListeners, + screenOptions, + ...rest +}: NativeStackNavigatorProps) { + // --- this is copy and pasted from the original native stack navigator --- + const {state, descriptors, navigation, NavigationContent} = + useNavigationBuilder< + StackNavigationState<ParamListBase>, + StackRouterOptions, + StackActionHelpers<ParamListBase>, + NativeStackNavigationOptionsWithAuth, + NativeStackNavigationEventMap + >(StackRouter, { + id, + initialRouteName, + children, + screenListeners, + screenOptions, + }) + React.useEffect( + () => + // @ts-expect-error: there may not be a tab navigator in parent + navigation?.addListener?.('tabPress', (e: any) => { + const isFocused = navigation.isFocused() + + // Run the operation in the next frame so we're sure all listeners have been run + // This is necessary to know if preventDefault() has been called + requestAnimationFrame(() => { + if ( + state.index > 0 && + isFocused && + !(e as EventArg<'tabPress', true>).defaultPrevented + ) { + // When user taps on already focused tab and we're inside the tab, + // reset the stack to replicate native behaviour + navigation.dispatch({ + ...StackActions.popToTop(), + target: state.key, + }) + } + }) + }), + [navigation, state.index, state.key], + ) + + // --- our custom logic starts here --- + const {hasSession} = useSession() + const activeRoute = state.routes[state.index] + const activeDescriptor = descriptors[activeRoute.key] + const activeRouteRequiresAuth = activeDescriptor.options.requireAuth ?? false + const onboardingState = useOnboardingState() + const {showLoggedOut} = useLoggedOutView() + const {setShowLoggedOut} = useLoggedOutViewControls() + const {isMobile} = useWebMediaQueries() + if (activeRouteRequiresAuth && !hasSession) { + return <LoggedOut /> + } + if (showLoggedOut) { + return <LoggedOut onDismiss={() => setShowLoggedOut(false)} /> + } + if (onboardingState.isActive) { + return <Onboarding /> + } + const newDescriptors: typeof descriptors = {} + for (let key in descriptors) { + const descriptor = descriptors[key] + const requireAuth = descriptor.options.requireAuth ?? false + newDescriptors[key] = { + ...descriptor, + render() { + if (requireAuth && !hasSession) { + return <View /> + } else { + return descriptor.render() + } + }, + } + } + return ( + <NavigationContent> + <NativeStackView + {...rest} + state={state} + navigation={navigation} + descriptors={newDescriptors} + /> + {isWeb && isMobile && <BottomBarWeb />} + {isWeb && !isMobile && ( + <> + <DesktopLeftNav /> + <DesktopRightNav /> + </> + )} + </NavigationContent> + ) +} + +export const createNativeStackNavigatorWithAuth = createNavigatorFactory< + StackNavigationState<ParamListBase>, + NativeStackNavigationOptionsWithAuth, + NativeStackNavigationEventMap, + typeof NativeStackNavigator +>(NativeStackNavigator) diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index 3237d2cdd..ff51ffe22 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -1,17 +1,17 @@ import React from 'react' import {View, StyleSheet} from 'react-native' import {useNavigationState} from '@react-navigation/native' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' -import {useDesktopRightNavItems} from 'lib/hooks/useDesktopRightNavItems' import {TextLink} from 'view/com/util/Link' import {getCurrentRoute} from 'lib/routes/helpers' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {usePinnedFeedsInfos} from '#/state/queries/feed' -export const DesktopFeeds = observer(function DesktopFeeds() { - const store = useStores() +export function DesktopFeeds() { const pal = usePalette('default') - const items = useDesktopRightNavItems(store.preferences.pinnedFeeds) + const {_} = useLingui() + const {feeds} = usePinnedFeedsInfos() const route = useNavigationState(state => { if (!state) { @@ -23,40 +23,40 @@ export const DesktopFeeds = observer(function DesktopFeeds() { return ( <View style={[styles.container, pal.view, pal.border]}> <FeedItem href="/" title="Following" current={route.name === 'Home'} /> - {items.map(item => { - try { - const params = route.params as Record<string, string> - const routeName = - item.collection === 'app.bsky.feed.generator' - ? 'ProfileFeed' - : 'ProfileList' - return ( - <FeedItem - key={item.uri} - href={item.href} - title={item.displayName} - current={ - route.name === routeName && - params.name === item.hostname && - params.rkey === item.rkey - } - /> - ) - } catch { - return null - } - })} + {feeds + .filter(f => f.displayName !== 'Following') + .map(feed => { + try { + const params = route.params as Record<string, string> + const routeName = + feed.type === 'feed' ? 'ProfileFeed' : 'ProfileList' + return ( + <FeedItem + key={feed.uri} + href={feed.route.href} + title={feed.displayName} + current={ + route.name === routeName && + params.name === feed.route.params.name && + params.rkey === feed.route.params.rkey + } + /> + ) + } catch { + return null + } + })} <View style={{paddingTop: 8, paddingBottom: 6}}> <TextLink type="lg" href="/feeds" - text="More feeds" + text={_(msg`More feeds`)} style={[pal.link]} /> </View> </View> ) -}) +} function FeedItem({ title, diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 39271605c..2ed294501 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -1,5 +1,4 @@ import React from 'react' -import {observer} from 'mobx-react-lite' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {PressableWithHover} from 'view/com/util/PressableWithHover' import { @@ -16,7 +15,6 @@ import {UserAvatar} from 'view/com/util/UserAvatar' import {Link} from 'view/com/util/Link' import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {s, colors} from 'lib/styles' import { @@ -39,18 +37,36 @@ import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers' import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types' import {router} from '../../../routes' import {makeProfileLink} from 'lib/routes/links' +import {useLingui} from '@lingui/react' +import {Trans, msg} from '@lingui/macro' +import {useProfileQuery} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {useUnreadNotifications} from '#/state/queries/notifications/unread' +import {useComposerControls} from '#/state/shell/composer' +import {useFetchHandle} from '#/state/queries/handle' +import {emitSoftReset} from '#/state/events' +import {useQueryClient} from '@tanstack/react-query' +import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed' +import {NavSignupCard} from '#/view/shell/NavSignupCard' +import {truncateAndInvalidate} from '#/state/queries/util' -const ProfileCard = observer(function ProfileCardImpl() { - const store = useStores() +function ProfileCard() { + const {currentAccount} = useSession() + const {isLoading, data: profile} = useProfileQuery({did: currentAccount!.did}) const {isDesktop} = useWebMediaQueries() + const {_} = useLingui() const size = 48 - return store.me.handle ? ( + + return !isLoading && profile ? ( <Link - href={makeProfileLink(store.me)} + href={makeProfileLink({ + did: currentAccount!.did, + handle: currentAccount!.handle, + })} style={[styles.profileCard, !isDesktop && styles.profileCardTablet]} - title="My Profile" + title={_(msg`My Profile`)} asAnchor> - <UserAvatar avatar={store.me.avatar} size={size} /> + <UserAvatar avatar={profile.avatar} size={size} /> </Link> ) : ( <View style={[styles.profileCard, !isDesktop && styles.profileCardTablet]}> @@ -61,12 +77,13 @@ const ProfileCard = observer(function ProfileCardImpl() { /> </View> ) -}) +} function BackBtn() { const {isTablet} = useWebMediaQueries() const pal = usePalette('default') const navigation = useNavigation<NavigationProp>() + const {_} = useLingui() const shouldShow = useNavigationState(state => !isStateAtTabRoot(state)) const onPressBack = React.useCallback(() => { @@ -86,7 +103,7 @@ function BackBtn() { onPress={onPressBack} style={styles.backBtn} accessibilityRole="button" - accessibilityLabel="Go back" + accessibilityLabel={_(msg`Go back`)} accessibilityHint=""> <FontAwesomeIcon size={24} @@ -104,15 +121,10 @@ interface NavItemProps { iconFilled: JSX.Element label: string } -const NavItem = observer(function NavItemImpl({ - count, - href, - icon, - iconFilled, - label, -}: NavItemProps) { +function NavItem({count, href, icon, iconFilled, label}: NavItemProps) { const pal = usePalette('default') - const store = useStores() + const queryClient = useQueryClient() + const {currentAccount} = useSession() const {isDesktop, isTablet} = useWebMediaQueries() const [pathName] = React.useMemo(() => router.matchPath(href), [href]) const currentRouteInfo = useNavigationState(state => { @@ -125,7 +137,7 @@ const NavItem = observer(function NavItemImpl({ currentRouteInfo.name === 'Profile' ? isTab(currentRouteInfo.name, pathName) && (currentRouteInfo.params as CommonNavigatorParams['Profile']).name === - store.me.handle + currentAccount?.handle : isTab(currentRouteInfo.name, pathName) const {onPress} = useLinkProps({to: href}) const onPressWrapped = React.useCallback( @@ -135,12 +147,16 @@ const NavItem = observer(function NavItemImpl({ } e.preventDefault() if (isCurrent) { - store.emitScreenSoftReset() + emitSoftReset() } else { + if (href === '/notifications') { + // fetch new notifs on view + truncateAndInvalidate(queryClient, NOTIFS_RQKEY()) + } onPress() } }, - [onPress, isCurrent, store], + [onPress, isCurrent, queryClient, href], ) return ( @@ -179,12 +195,16 @@ const NavItem = observer(function NavItemImpl({ )} </PressableWithHover> ) -}) +} function ComposeBtn() { - const store = useStores() + const {currentAccount} = useSession() const {getState} = useNavigation() + const {openComposer} = useComposerControls() + const {_} = useLingui() const {isTablet} = useWebMediaQueries() + const [isFetchingHandle, setIsFetchingHandle] = React.useState(false) + const fetchHandle = useFetchHandle() const getProfileHandle = async () => { const {routes} = getState() @@ -196,13 +216,21 @@ function ComposeBtn() { ).name if (handle.startsWith('did:')) { - const cached = await store.profiles.cache.get(handle) - const profile = cached ? cached.data : undefined - // if we can't resolve handle, set to undefined - handle = profile?.handle || undefined + try { + setIsFetchingHandle(true) + handle = await fetchHandle(handle) + } catch (e) { + handle = undefined + } finally { + setIsFetchingHandle(false) + } } - if (!handle || handle === store.me.handle || handle === 'handle.invalid') + if ( + !handle || + handle === currentAccount?.handle || + handle === 'handle.invalid' + ) return undefined return handle @@ -212,17 +240,18 @@ function ComposeBtn() { } const onPressCompose = async () => - store.shell.openComposer({mention: await getProfileHandle()}) + openComposer({mention: await getProfileHandle()}) if (isTablet) { return null } return ( <TouchableOpacity + disabled={isFetchingHandle} style={[styles.newPostBtn]} onPress={onPressCompose} accessibilityRole="button" - accessibilityLabel="New post" + accessibilityLabel={_(msg`New post`)} accessibilityHint=""> <View style={styles.newPostBtnIconWrapper}> <ComposeIcon2 @@ -232,16 +261,18 @@ function ComposeBtn() { /> </View> <Text type="button" style={styles.newPostBtnLabel}> - New Post + <Trans>New Post</Trans> </Text> </TouchableOpacity> ) } -export const DesktopLeftNav = observer(function DesktopLeftNav() { - const store = useStores() +export function DesktopLeftNav() { + const {hasSession, currentAccount} = useSession() const pal = usePalette('default') + const {_} = useLingui() const {isDesktop, isTablet} = useWebMediaQueries() + const numUnread = useUnreadNotifications() return ( <View @@ -251,8 +282,16 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() { pal.view, pal.border, ]}> - {store.session.hasSession && <ProfileCard />} + {hasSession ? ( + <ProfileCard /> + ) : isDesktop ? ( + <View style={{paddingHorizontal: 12}}> + <NavSignupCard /> + </View> + ) : null} + <BackBtn /> + <NavItem href="/" icon={<HomeIcon size={isDesktop ? 24 : 28} style={pal.text} />} @@ -263,7 +302,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() { style={pal.text} /> } - label="Home" + label={_(msg`Home`)} /> <NavItem href="/search" @@ -281,7 +320,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() { style={pal.text} /> } - label="Search" + label={_(msg`Search`)} /> <NavItem href="/feeds" @@ -299,105 +338,109 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() { size={isDesktop ? 24 : 28} /> } - label="Feeds" + label={_(msg`Feeds`)} /> - <NavItem - href="/notifications" - count={store.me.notifications.unreadCountLabel} - icon={ - <BellIcon - strokeWidth={2} - size={isDesktop ? 24 : 26} - style={pal.text} + + {hasSession && ( + <> + <NavItem + href="/notifications" + count={numUnread} + icon={ + <BellIcon + strokeWidth={2} + size={isDesktop ? 24 : 26} + style={pal.text} + /> + } + iconFilled={ + <BellIconSolid + strokeWidth={1.5} + size={isDesktop ? 24 : 26} + style={pal.text} + /> + } + label={_(msg`Notifications`)} /> - } - iconFilled={ - <BellIconSolid - strokeWidth={1.5} - size={isDesktop ? 24 : 26} - style={pal.text} + <NavItem + href="/lists" + icon={ + <ListIcon + style={pal.text} + size={isDesktop ? 26 : 30} + strokeWidth={2} + /> + } + iconFilled={ + <ListIcon + style={pal.text} + size={isDesktop ? 26 : 30} + strokeWidth={3} + /> + } + label={_(msg`Lists`)} /> - } - label="Notifications" - /> - <NavItem - href="/lists" - icon={ - <ListIcon - style={pal.text} - size={isDesktop ? 26 : 30} - strokeWidth={2} + <NavItem + href="/moderation" + icon={ + <HandIcon + style={pal.text} + size={isDesktop ? 24 : 27} + strokeWidth={5.5} + /> + } + iconFilled={ + <FontAwesomeIcon + icon="hand" + style={pal.text as FontAwesomeIconStyle} + size={isDesktop ? 20 : 26} + /> + } + label={_(msg`Moderation`)} /> - } - iconFilled={ - <ListIcon - style={pal.text} - size={isDesktop ? 26 : 30} - strokeWidth={3} + <NavItem + href={currentAccount ? makeProfileLink(currentAccount) : '/'} + icon={ + <UserIcon + strokeWidth={1.75} + size={isDesktop ? 28 : 30} + style={pal.text} + /> + } + iconFilled={ + <UserIconSolid + strokeWidth={1.75} + size={isDesktop ? 28 : 30} + style={pal.text} + /> + } + label="Profile" /> - } - label="Lists" - /> - <NavItem - href="/moderation" - icon={ - <HandIcon - style={pal.text} - size={isDesktop ? 24 : 27} - strokeWidth={5.5} + <NavItem + href="/settings" + icon={ + <CogIcon + strokeWidth={1.75} + size={isDesktop ? 28 : 32} + style={pal.text} + /> + } + iconFilled={ + <CogIconSolid + strokeWidth={1.5} + size={isDesktop ? 28 : 32} + style={pal.text} + /> + } + label={_(msg`Settings`)} /> - } - iconFilled={ - <FontAwesomeIcon - icon="hand" - style={pal.text as FontAwesomeIconStyle} - size={isDesktop ? 20 : 26} - /> - } - label="Moderation" - /> - {store.session.hasSession && ( - <NavItem - href={makeProfileLink(store.me)} - icon={ - <UserIcon - strokeWidth={1.75} - size={isDesktop ? 28 : 30} - style={pal.text} - /> - } - iconFilled={ - <UserIconSolid - strokeWidth={1.75} - size={isDesktop ? 28 : 30} - style={pal.text} - /> - } - label="Profile" - /> + + <ComposeBtn /> + </> )} - <NavItem - href="/settings" - icon={ - <CogIcon - strokeWidth={1.75} - size={isDesktop ? 28 : 32} - style={pal.text} - /> - } - iconFilled={ - <CogIconSolid - strokeWidth={1.5} - size={isDesktop ? 28 : 32} - style={pal.text} - /> - } - label="Settings" - /> - {store.session.hasSession && <ComposeBtn />} </View> ) -}) +} const styles = StyleSheet.create({ leftNav: { diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index 84d7d7854..9a5186549 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.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 {usePalette} from 'lib/hooks/usePalette' @@ -9,15 +8,19 @@ import {Text} from 'view/com/util/text/Text' import {TextLink} from 'view/com/util/Link' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants' import {s} from 'lib/styles' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {pluralize} from 'lib/strings/helpers' import {formatCount} from 'view/com/util/numeric/format' +import {useModalControls} from '#/state/modals' +import {useLingui} from '@lingui/react' +import {Plural, Trans, msg, plural} from '@lingui/macro' +import {useSession} from '#/state/session' +import {useInviteCodesQuery} from '#/state/queries/invites' -export const DesktopRightNav = observer(function DesktopRightNavImpl() { - const store = useStores() +export function DesktopRightNav() { const pal = usePalette('default') const palError = usePalette('error') + const {_} = useLingui() + const {isSandbox, hasSession, currentAccount} = useSession() const {isTablet} = useWebMediaQueries() if (isTablet) { @@ -26,10 +29,22 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() { return ( <View style={[styles.rightNav, pal.view]}> - {store.session.hasSession && <DesktopSearch />} - {store.session.hasSession && <DesktopFeeds />} - <View style={styles.message}> - {store.session.isSandbox ? ( + <DesktopSearch /> + + {hasSession && ( + <View style={{paddingTop: 18, marginBottom: 18}}> + <DesktopFeeds /> + </View> + )} + + <View + style={[ + styles.message, + { + paddingTop: hasSession ? 0 : 18, + }, + ]}> + {isSandbox ? ( <View style={[palError.view, styles.messageLine, s.p10]}> <Text type="md" style={[palError.text, s.bold]}> SANDBOX. Posts and accounts are not permanent. @@ -37,23 +52,27 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() { </View> ) : undefined} <View style={[s.flexRow]}> - <TextLink - type="md" - style={pal.link} - href={FEEDBACK_FORM_URL({ - email: store.session.currentSession?.email, - handle: store.session.currentSession?.handle, - })} - text="Send feedback" - /> - <Text type="md" style={pal.textLight}> - · - </Text> + {hasSession && ( + <> + <TextLink + type="md" + style={pal.link} + href={FEEDBACK_FORM_URL({ + email: currentAccount!.email, + handle: currentAccount!.handle, + })} + text={_(msg`Feedback`)} + /> + <Text type="md" style={pal.textLight}> + · + </Text> + </> + )} <TextLink type="md" style={pal.link} href="https://blueskyweb.xyz/support/privacy-policy" - text="Privacy" + text={_(msg`Privacy`)} /> <Text type="md" style={pal.textLight}> · @@ -62,7 +81,7 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() { type="md" style={pal.link} href="https://blueskyweb.xyz/support/tos" - text="Terms" + text={_(msg`Terms`)} /> <Text type="md" style={pal.textLight}> · @@ -71,52 +90,80 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() { type="md" style={pal.link} href={HELP_DESK_URL} - text="Help" + text={_(msg`Help`)} /> </View> </View> - <InviteCodes /> + + {hasSession && <InviteCodes />} </View> ) -}) +} -const InviteCodes = observer(function InviteCodesImpl() { - const store = useStores() +function InviteCodes() { const pal = usePalette('default') - - const {invitesAvailable} = store.me + const {openModal} = useModalControls() + const {data: invites} = useInviteCodesQuery() + const invitesAvailable = invites?.available?.length ?? 0 + const {_} = useLingui() const onPress = React.useCallback(() => { - store.shell.openModal({name: 'invite-codes'}) - }, [store]) + openModal({name: 'invite-codes'}) + }, [openModal]) + + if (!invites) { + return null + } + + if (invites?.disabled) { + return ( + <View style={[styles.inviteCodes, pal.border]}> + <FontAwesomeIcon + icon="ticket" + style={[styles.inviteCodesIcon, pal.textLight]} + size={16} + /> + <Text type="md-medium" style={pal.textLight}> + <Trans> + Your invite codes are hidden when logged in using an App Password + </Trans> + </Text> + </View> + ) + } + return ( <TouchableOpacity style={[styles.inviteCodes, pal.border]} onPress={onPress} accessibilityRole="button" - accessibilityLabel={ - invitesAvailable === 1 - ? 'Invite codes: 1 available' - : `Invite codes: ${invitesAvailable} available` - } - accessibilityHint="Opens list of invite codes"> + accessibilityLabel={_( + plural(invitesAvailable, { + one: 'Invite codes: # available', + other: 'Invite codes: # available', + }), + )} + accessibilityHint={_(msg`Opens list of invite codes`)}> <FontAwesomeIcon icon="ticket" style={[ styles.inviteCodesIcon, - store.me.invitesAvailable > 0 ? pal.link : pal.textLight, + invitesAvailable > 0 ? pal.link : pal.textLight, ]} size={16} /> <Text type="md-medium" - style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> - {formatCount(store.me.invitesAvailable)} invite{' '} - {pluralize(store.me.invitesAvailable, 'code')} available + style={invitesAvailable > 0 ? pal.link : pal.textLight}> + <Plural + value={formatCount(invitesAvailable)} + one="# invite code available" + other="# invite codes available" + /> </Text> </TouchableOpacity> ) -}) +} const styles = StyleSheet.create({ rightNav: { @@ -142,9 +189,10 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingVertical: 12, flexDirection: 'row', - alignItems: 'center', }, inviteCodesIcon: { + marginTop: 2, marginRight: 6, + flexShrink: 0, }, }) diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index caecea4a8..f899431b6 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -1,56 +1,150 @@ import React from 'react' -import {TextInput, View, StyleSheet, TouchableOpacity} from 'react-native' +import { + ViewStyle, + TextInput, + View, + StyleSheet, + TouchableOpacity, + ActivityIndicator, +} from 'react-native' import {useNavigation, StackActions} from '@react-navigation/native' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' +import { + AppBskyActorDefs, + moderateProfile, + ProfileModeration, +} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {s} from '#/lib/styles' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {makeProfileLink} from '#/lib/routes/links' +import {Link} from '#/view/com/util/Link' import {usePalette} from 'lib/hooks/usePalette' import {MagnifyingGlassIcon2} from 'lib/icons' import {NavigationProp} from 'lib/routes/types' -import {ProfileCard} from 'view/com/profile/ProfileCard' import {Text} from 'view/com/util/text/Text' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' +import {useModerationOpts} from '#/state/queries/preferences' -export const DesktopSearch = observer(function DesktopSearch() { - const store = useStores() +export function SearchResultCard({ + profile, + style, + moderation, +}: { + profile: AppBskyActorDefs.ProfileViewBasic + style: ViewStyle + moderation: ProfileModeration +}) { const pal = usePalette('default') - const textInput = React.useRef<TextInput>(null) - const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) - const [query, setQuery] = React.useState<string>('') - const autocompleteView = React.useMemo<UserAutocompleteModel>( - () => new UserAutocompleteModel(store), - [store], + + return ( + <Link + href={makeProfileLink(profile)} + title={profile.handle} + asAnchor + anchorNoUnderline> + <View + style={[ + pal.border, + style, + { + borderTopWidth: 1, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingVertical: 8, + paddingHorizontal: 12, + }, + ]}> + <UserAvatar + size={40} + avatar={profile.avatar} + moderation={moderation.avatar} + /> + <View style={{flex: 1}}> + <Text + type="lg" + style={[s.bold, pal.text]} + numberOfLines={1} + lineHeight={1.2}> + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.profile, + )} + </Text> + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + {sanitizeHandle(profile.handle, '@')} + </Text> + </View> + </View> + </Link> ) +} + +export function DesktopSearch() { + const {_} = useLingui() + const pal = usePalette('default') const navigation = useNavigation<NavigationProp>() + const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>( + undefined, + ) + const [isActive, setIsActive] = React.useState<boolean>(false) + const [isFetching, setIsFetching] = React.useState<boolean>(false) + const [query, setQuery] = React.useState<string>('') + const [searchResults, setSearchResults] = React.useState< + AppBskyActorDefs.ProfileViewBasic[] + >([]) - // initial setup - React.useEffect(() => { - if (store.me.did) { - autocompleteView.setup() - } - }, [autocompleteView, store.me.did]) + const moderationOpts = useModerationOpts() + const search = useActorAutocompleteFn() - const onChangeQuery = React.useCallback( - (text: string) => { + const onChangeText = React.useCallback( + async (text: string) => { setQuery(text) - if (text.length > 0 && isInputFocused) { - autocompleteView.setActive(true) - autocompleteView.setPrefix(text) + + if (text.length > 0) { + setIsFetching(true) + setIsActive(true) + + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + + searchDebounceTimeout.current = setTimeout(async () => { + const results = await search({query: text}) + + if (results) { + setSearchResults(results) + setIsFetching(false) + } + }, 300) } else { - autocompleteView.setActive(false) + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + setSearchResults([]) + setIsFetching(false) + setIsActive(false) } }, - [setQuery, autocompleteView, isInputFocused], + [setQuery, search, setSearchResults], ) const onPressCancelSearch = React.useCallback(() => { setQuery('') - autocompleteView.setActive(false) - }, [setQuery, autocompleteView]) - + setIsActive(false) + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + }, [setQuery]) const onSubmit = React.useCallback(() => { + setIsActive(false) + if (!query.length) return + setSearchResults([]) + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) navigation.dispatch(StackActions.push('Search', {q: query})) - autocompleteView.setActive(false) - }, [query, navigation, autocompleteView]) + }, [query, navigation, setSearchResults]) return ( <View style={[styles.container, pal.view]}> @@ -63,19 +157,16 @@ export const DesktopSearch = observer(function DesktopSearch() { /> <TextInput testID="searchTextInput" - ref={textInput} - placeholder="Search" + placeholder={_(msg`Search`)} placeholderTextColor={pal.colors.textLight} selectTextOnFocus returnKeyType="search" value={query} style={[pal.textLight, styles.input]} - onFocus={() => setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - onChangeText={onChangeQuery} + onChangeText={onChangeText} onSubmitEditing={onSubmit} accessibilityRole="search" - accessibilityLabel="Search" + accessibilityLabel={_(msg`Search`)} accessibilityHint="" /> {query ? ( @@ -83,11 +174,11 @@ export const DesktopSearch = observer(function DesktopSearch() { <TouchableOpacity onPress={onPressCancelSearch} accessibilityRole="button" - accessibilityLabel="Cancel search" + accessibilityLabel={_(msg`Cancel search`)} accessibilityHint="Exits inputting search query" onAccessibilityEscape={onPressCancelSearch}> <Text type="lg" style={[pal.link]}> - Cancel + <Trans>Cancel</Trans> </Text> </TouchableOpacity> </View> @@ -95,32 +186,42 @@ export const DesktopSearch = observer(function DesktopSearch() { </View> </View> - {query !== '' && ( + {query !== '' && isActive && moderationOpts && ( <View style={[pal.view, pal.borderDark, styles.resultsContainer]}> - {autocompleteView.suggestions.length ? ( + {isFetching ? ( + <View style={{padding: 8}}> + <ActivityIndicator /> + </View> + ) : ( <> - {autocompleteView.suggestions.map((item, i) => ( - <ProfileCard key={item.did} profile={item} noBorder={i === 0} /> - ))} + {searchResults.length ? ( + searchResults.map((item, i) => ( + <SearchResultCard + key={item.did} + profile={item} + moderation={moderateProfile(item, moderationOpts)} + style={i === 0 ? {borderTopWidth: 0} : {}} + /> + )) + ) : ( + <View> + <Text style={[pal.textLight, styles.noResults]}> + <Trans>No results found for {query}</Trans> + </Text> + </View> + )} </> - ) : ( - <View> - <Text style={[pal.textLight, styles.noResults]}> - No results found for {autocompleteView.prefix} - </Text> - </View> )} </View> )} </View> ) -}) +} const styles = StyleSheet.create({ container: { position: 'relative', width: 300, - paddingBottom: 18, }, search: { paddingHorizontal: 16, @@ -150,15 +251,11 @@ const styles = StyleSheet.create({ paddingVertical: 7, }, resultsContainer: { - // @ts-ignore supported by web - // position: 'fixed', marginTop: 10, - flexDirection: 'column', width: 300, borderWidth: 1, borderRadius: 6, - paddingVertical: 4, }, noResults: { textAlign: 'center', diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 703edf27a..5562af9ac 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -1,5 +1,4 @@ import React from 'react' -import {observer} from 'mobx-react-lite' import {StatusBar} from 'expo-status-bar' import { DimensionValue, @@ -11,7 +10,6 @@ import { import {useSafeAreaInsets} from 'react-native-safe-area-context' import {Drawer} from 'react-native-drawer-layout' import {useNavigationState} from '@react-navigation/native' -import {useStores} from 'state/index' import {ModalsContainer} from 'view/com/modals/Modal' import {Lightbox} from 'view/com/lightbox/Lightbox' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' @@ -25,20 +23,19 @@ import { SafeAreaProvider, initialWindowMetrics, } from 'react-native-safe-area-context' -import {useOTAUpdate} from 'lib/hooks/useOTAUpdate' import { useIsDrawerOpen, useSetDrawerOpen, useIsDrawerSwipeDisabled, } from '#/state/shell' import {isAndroid} from 'platform/detection' +import {useSession} from '#/state/session' +import {useCloseAnyActiveElement} from '#/state/util' -const ShellInner = observer(function ShellInnerImpl() { - const store = useStores() +function ShellInner() { const isDrawerOpen = useIsDrawerOpen() const isDrawerSwipeDisabled = useIsDrawerSwipeDisabled() const setIsDrawerOpen = useSetDrawerOpen() - useOTAUpdate() // this hook polls for OTA updates every few seconds const winDim = useWindowDimensions() const safeAreaInsets = useSafeAreaInsets() const containerPadding = React.useMemo( @@ -55,18 +52,20 @@ const ShellInner = observer(function ShellInnerImpl() { [setIsDrawerOpen], ) const canGoBack = useNavigationState(state => !isStateAtTabRoot(state)) + const {hasSession} = useSession() + const closeAnyActiveElement = useCloseAnyActiveElement() + React.useEffect(() => { let listener = {remove() {}} if (isAndroid) { listener = BackHandler.addEventListener('hardwareBackPress', () => { - setIsDrawerOpen(false) - return store.shell.closeAnyActiveElement() + return closeAnyActiveElement() }) } return () => { listener.remove() } - }, [store, setIsDrawerOpen]) + }, [closeAnyActiveElement]) return ( <> @@ -78,28 +77,19 @@ const ShellInner = observer(function ShellInnerImpl() { onOpen={onOpenDrawer} onClose={onCloseDrawer} swipeEdgeWidth={winDim.width / 2} - swipeEnabled={ - !canGoBack && store.session.hasSession && !isDrawerSwipeDisabled - }> + swipeEnabled={!canGoBack && hasSession && !isDrawerSwipeDisabled}> <TabsNavigator /> </Drawer> </ErrorBoundary> </View> - <Composer - active={store.shell.isComposerActive} - winHeight={winDim.height} - replyTo={store.shell.composerOpts?.replyTo} - onPost={store.shell.composerOpts?.onPost} - quote={store.shell.composerOpts?.quote} - mention={store.shell.composerOpts?.mention} - /> + <Composer winHeight={winDim.height} /> <ModalsContainer /> <Lightbox /> </> ) -}) +} -export const Shell: React.FC = observer(function ShellImpl() { +export const Shell: React.FC = function ShellImpl() { const pal = usePalette('default') const theme = useTheme() return ( @@ -112,7 +102,7 @@ export const Shell: React.FC = observer(function ShellImpl() { </View> </SafeAreaProvider> ) -}) +} const styles = StyleSheet.create({ outerContainer: { diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 843d0b284..38da860bd 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -1,9 +1,5 @@ import React, {useEffect} from 'react' -import {observer} from 'mobx-react-lite' import {View, StyleSheet, TouchableOpacity} from 'react-native' -import {useStores} from 'state/index' -import {DesktopLeftNav} from './desktop/LeftNav' -import {DesktopRightNav} from './desktop/RightNav' import {ErrorBoundary} from '../com/util/ErrorBoundary' import {Lightbox} from '../com/lightbox/Lightbox' import {ModalsContainer} from '../com/modals/Modal' @@ -13,30 +9,29 @@ import {s, colors} from 'lib/styles' import {RoutesContainer, FlatNavigator} from '../../Navigation' import {DrawerContent} from './Drawer' import {useWebMediaQueries} from '../../lib/hooks/useWebMediaQueries' -import {BottomBarWeb} from './bottom-bar/BottomBarWeb' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' import {useAuxClick} from 'lib/hooks/useAuxClick' +import {t} from '@lingui/macro' import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' +import {useCloseAllActiveElements} from '#/state/util' -const ShellInner = observer(function ShellInnerImpl() { - const store = useStores() +function ShellInner() { const isDrawerOpen = useIsDrawerOpen() const setDrawerOpen = useSetDrawerOpen() - const {isDesktop, isMobile} = useWebMediaQueries() + const {isDesktop} = useWebMediaQueries() const navigator = useNavigation<NavigationProp>() + const closeAllActiveElements = useCloseAllActiveElements() + useAuxClick() useEffect(() => { - navigator.addListener('state', () => { - setDrawerOpen(false) - store.shell.closeAnyActiveElement() + const unsubscribe = navigator.addListener('state', () => { + closeAllActiveElements() }) - }, [navigator, store.shell, setDrawerOpen]) + return unsubscribe + }, [navigator, closeAllActiveElements]) - const showBottomBar = isMobile && !store.onboarding.isActive - const showSideNavs = - !isMobile && store.session.hasSession && !store.onboarding.isActive return ( <View style={[s.hContentRegion, {overflow: 'hidden'}]}> <View style={s.hContentRegion}> @@ -44,28 +39,14 @@ const ShellInner = observer(function ShellInnerImpl() { <FlatNavigator /> </ErrorBoundary> </View> - {showSideNavs && ( - <> - <DesktopLeftNav /> - <DesktopRightNav /> - </> - )} - <Composer - active={store.shell.isComposerActive} - winHeight={0} - replyTo={store.shell.composerOpts?.replyTo} - quote={store.shell.composerOpts?.quote} - onPost={store.shell.composerOpts?.onPost} - mention={store.shell.composerOpts?.mention} - /> - {showBottomBar && <BottomBarWeb />} + <Composer winHeight={0} /> <ModalsContainer /> <Lightbox /> {!isDesktop && isDrawerOpen && ( <TouchableOpacity onPress={() => setDrawerOpen(false)} style={styles.drawerMask} - accessibilityLabel="Close navigation footer" + accessibilityLabel={t`Close navigation footer`} accessibilityHint="Closes bottom navigation bar"> <View style={styles.drawerContainer}> <DrawerContent /> @@ -74,9 +55,9 @@ const ShellInner = observer(function ShellInnerImpl() { )} </View> ) -}) +} -export const Shell: React.FC = observer(function ShellImpl() { +export const Shell: React.FC = function ShellImpl() { const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark) return ( <View style={[s.hContentRegion, pageBg]}> @@ -85,7 +66,7 @@ export const Shell: React.FC = observer(function ShellImpl() { </RoutesContainer> </View> ) -}) +} const styles = StyleSheet.create({ bgLight: { |