diff options
Diffstat (limited to 'src/view')
27 files changed, 660 insertions, 130 deletions
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index 6005ee3a5..2e16b13bb 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -42,10 +42,11 @@ export function Step2({ const {isMobile} = useWebMediaQueries() const onPressRequest = React.useCallback(() => { - if ( - uiState.verificationPhone.length >= 9 && - parsePhoneNumber(uiState.verificationPhone, uiState.phoneCountry) - ) { + const phoneNumber = parsePhoneNumber( + uiState.verificationPhone, + uiState.phoneCountry, + ) + if (phoneNumber && phoneNumber.isValid()) { requestVerificationCode({uiState, uiDispatch, _}) } else { uiDispatch({ diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx index 2fd265535..3a52abf80 100644 --- a/src/view/com/auth/create/Step3.tsx +++ b/src/view/com/auth/create/Step3.tsx @@ -43,7 +43,7 @@ export function Step3({ /> <Text type="lg" style={[pal.text, s.pl5, s.pt10]}> <Trans>Your full handle will be</Trans>{' '} - <Text type="lg-bold" style={[pal.text, s.ml5]}> + <Text type="lg-bold" style={pal.text}> @{createFullHandle(uiState.handle, uiState.userDomain)} </Text> </Text> diff --git a/src/view/com/auth/login/ForgotPasswordForm.tsx b/src/view/com/auth/login/ForgotPasswordForm.tsx index f9bb64f98..79399d85d 100644 --- a/src/view/com/auth/login/ForgotPasswordForm.tsx +++ b/src/view/com/auth/login/ForgotPasswordForm.tsx @@ -195,6 +195,29 @@ export const ForgotPasswordForm = ({ </Text> ) : undefined} </View> + <View + style={[ + s.flexRow, + s.alignCenter, + s.mt20, + s.mb20, + pal.border, + s.borderBottom1, + {alignSelf: 'center', width: '90%'}, + ]} + /> + <View style={[s.flexRow, s.justifyCenter]}> + <TouchableOpacity + testID="skipSendEmailButton" + onPress={onEmailSent} + accessibilityRole="button" + accessibilityLabel={_(msg`Go to next`)} + accessibilityHint={_(msg`Navigates to the next screen`)}> + <Text type="xl" style={[pal.link, s.pr5]}> + <Trans>Already have a code?</Trans> + </Text> + </TouchableOpacity> + </View> </View> </> ) diff --git a/src/view/com/auth/login/SetNewPasswordForm.tsx b/src/view/com/auth/login/SetNewPasswordForm.tsx index 630c6afde..6d1584c86 100644 --- a/src/view/com/auth/login/SetNewPasswordForm.tsx +++ b/src/view/com/auth/login/SetNewPasswordForm.tsx @@ -14,6 +14,7 @@ import {isNetworkError} from 'lib/strings/errors' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {cleanError} from 'lib/strings/errors' +import {checkAndFormatResetCode} from 'lib/strings/password' import {logger} from '#/logger' import {styles} from './styles' import {Trans, msg} from '@lingui/macro' @@ -46,14 +47,26 @@ export const SetNewPasswordForm = ({ const [password, setPassword] = useState<string>('') const onPressNext = async () => { + // Check that the code is correct. We do this again just incase the user enters the code after their pw and we + // don't get to call onBlur first + const formattedCode = checkAndFormatResetCode(resetCode) + // TODO Better password strength check + if (!formattedCode || !password) { + setError( + _( + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, + ), + ) + return + } + setError('') setIsProcessing(true) try { const agent = new BskyAgent({service: serviceUrl}) - const token = resetCode.replace(/\s/g, '') await agent.com.atproto.server.resetPassword({ - token, + token: formattedCode, password, }) onPasswordSet() @@ -71,6 +84,19 @@ export const SetNewPasswordForm = ({ } } + const onBlur = () => { + const formattedCode = checkAndFormatResetCode(resetCode) + if (!formattedCode) { + setError( + _( + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, + ), + ) + return + } + setResetCode(formattedCode) + } + return ( <> <View> @@ -100,9 +126,11 @@ export const SetNewPasswordForm = ({ autoCapitalize="none" autoCorrect={false} keyboardAppearance={theme.colorScheme} - autoFocus + autoComplete="off" value={resetCode} onChangeText={setResetCode} + onFocus={() => setError('')} + onBlur={onBlur} editable={!isProcessing} accessible={true} accessibilityLabel={_(msg`Reset code`)} @@ -123,6 +151,7 @@ export const SetNewPasswordForm = ({ placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoCorrect={false} + autoComplete="new-password" keyboardAppearance={theme.colorScheme} secureTextEntry value={password} @@ -160,6 +189,7 @@ export const SetNewPasswordForm = ({ ) : ( <TouchableOpacity testID="setNewPasswordButton" + // Check the code before running the callback onPress={onPressNext} accessibilityRole="button" accessibilityLabel={_(msg`Go to next`)} diff --git a/src/view/com/modals/AddAppPasswords.tsx b/src/view/com/modals/AddAppPasswords.tsx index 5048a0041..a8913dd54 100644 --- a/src/view/com/modals/AddAppPasswords.tsx +++ b/src/view/com/modals/AddAppPasswords.tsx @@ -62,7 +62,8 @@ export function Component({}: {}) { const {_} = useLingui() const {closeModal} = useModalControls() const {data: passwords} = useAppPasswordsQuery() - const createMutation = useAppPasswordCreateMutation() + const {mutateAsync: mutateAppPassword, isPending} = + useAppPasswordCreateMutation() const [name, setName] = useState( shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)], ) @@ -107,7 +108,7 @@ export function Component({}: {}) { } try { - const newPassword = await createMutation.mutateAsync({name}) + const newPassword = await mutateAppPassword({name}) if (newPassword) { setAppPassword(newPassword.password) } else { @@ -170,13 +171,10 @@ export function Component({}: {}) { autoFocus={true} maxLength={32} selectTextOnFocus={true} - multiline={true} // need this to be true otherwise selectTextOnFocus doesn't work - numberOfLines={1} // hack for multiline so only one line shows (android) - scrollEnabled={false} // hack for multiline so only one line shows (ios) - blurOnSubmit={true} // hack for multiline so it submits - editable={!appPassword} + blurOnSubmit={true} + editable={!isPending} returnKeyType="done" - onEndEditing={createAppPassword} + onSubmitEditing={createAppPassword} accessible={true} accessibilityLabel={_(msg`Name`)} accessibilityHint={_(msg`Input name for app password`)} @@ -253,7 +251,6 @@ const styles = StyleSheet.create({ width: '100%', paddingVertical: 10, paddingHorizontal: 8, - marginTop: 6, fontSize: 17, letterSpacing: 0.25, fontWeight: '400', diff --git a/src/view/com/modals/ChangePassword.tsx b/src/view/com/modals/ChangePassword.tsx new file mode 100644 index 000000000..d8add9794 --- /dev/null +++ b/src/view/com/modals/ChangePassword.tsx @@ -0,0 +1,336 @@ +import React, {useState} from 'react' +import { + ActivityIndicator, + SafeAreaView, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' +import {ScrollView} from './util' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {TextInput} from './util' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {isAndroid, isWeb} from 'platform/detection' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {cleanError, isNetworkError} from 'lib/strings/errors' +import {checkAndFormatResetCode} from 'lib/strings/password' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useSession, getAgent} from '#/state/session' +import * as EmailValidator from 'email-validator' +import {logger} from '#/logger' + +enum Stages { + RequestCode, + ChangePassword, + Done, +} + +export const snapPoints = isAndroid ? ['90%'] : ['45%'] + +export function Component() { + const pal = usePalette('default') + const {currentAccount} = useSession() + const {_} = useLingui() + const [stage, setStage] = useState<Stages>(Stages.RequestCode) + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [resetCode, setResetCode] = useState<string>('') + const [newPassword, setNewPassword] = useState<string>('') + const [error, setError] = useState<string>('') + const {isMobile} = useWebMediaQueries() + const {closeModal} = useModalControls() + const agent = getAgent() + + const onRequestCode = async () => { + if ( + !currentAccount?.email || + !EmailValidator.validate(currentAccount.email) + ) { + return setError(_(msg`Your email appears to be invalid.`)) + } + + setError('') + setIsProcessing(true) + try { + await agent.com.atproto.server.requestPasswordReset({ + email: currentAccount.email, + }) + setStage(Stages.ChangePassword) + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to request password reset', {error: e}) + if (isNetworkError(e)) { + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + } else { + setError(cleanError(errMsg)) + } + } finally { + setIsProcessing(false) + } + } + + const onChangePassword = async () => { + const formattedCode = checkAndFormatResetCode(resetCode) + // TODO Better password strength check + if (!formattedCode || !newPassword) { + setError( + _( + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, + ), + ) + return + } + + setError('') + setIsProcessing(true) + try { + await agent.com.atproto.server.resetPassword({ + token: formattedCode, + password: newPassword, + }) + setStage(Stages.Done) + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to set new password', {error: e}) + if (isNetworkError(e)) { + setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + } else { + setError(cleanError(errMsg)) + } + } finally { + setIsProcessing(false) + } + } + + const onBlur = () => { + const formattedCode = checkAndFormatResetCode(resetCode) + if (!formattedCode) { + setError( + _( + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, + ), + ) + return + } + setResetCode(formattedCode) + } + + return ( + <SafeAreaView style={[pal.view, s.flex1]}> + <ScrollView + contentContainerStyle={[ + styles.container, + isMobile && styles.containerMobile, + ]} + keyboardShouldPersistTaps="handled"> + <View> + <View style={styles.titleSection}> + <Text type="title-lg" style={[pal.text, styles.title]}> + {stage !== Stages.Done ? 'Change Password' : 'Password Changed'} + </Text> + </View> + + <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> + {stage === Stages.RequestCode ? ( + <Trans> + If you want to change your password, we will send you a code to + verify that this is your account. + </Trans> + ) : stage === Stages.ChangePassword ? ( + <Trans> + Enter the code you received to change your password. + </Trans> + ) : ( + <Trans>Your password has been changed successfully!</Trans> + )} + </Text> + + {stage === Stages.RequestCode && ( + <View style={[s.flexRow, s.justifyCenter, s.mt10]}> + <TouchableOpacity + testID="skipSendEmailButton" + onPress={() => setStage(Stages.ChangePassword)} + accessibilityRole="button" + accessibilityLabel={_(msg`Go to next`)} + accessibilityHint={_(msg`Navigates to the next screen`)}> + <Text type="xl" style={[pal.link, s.pr5]}> + <Trans>Already have a code?</Trans> + </Text> + </TouchableOpacity> + </View> + )} + {stage === Stages.ChangePassword && ( + <View style={[pal.border, styles.group]}> + <View style={[styles.groupContent]}> + <FontAwesomeIcon + icon="ticket" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="codeInput" + style={[pal.text, styles.textInput]} + placeholder="Reset code" + placeholderTextColor={pal.colors.textLight} + value={resetCode} + onChangeText={setResetCode} + onFocus={() => setError('')} + onBlur={onBlur} + accessible={true} + accessibilityLabel={_(msg`Reset Code`)} + accessibilityHint="" + autoCapitalize="none" + autoCorrect={false} + autoComplete="off" + /> + </View> + <View + style={[ + pal.borderDark, + styles.groupContent, + styles.groupBottom, + ]}> + <FontAwesomeIcon + icon="lock" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="codeInput" + style={[pal.text, styles.textInput]} + placeholder="New password" + placeholderTextColor={pal.colors.textLight} + onChangeText={setNewPassword} + secureTextEntry + accessible={true} + accessibilityLabel={_(msg`New Password`)} + accessibilityHint="" + autoCapitalize="none" + autoComplete="new-password" + /> + </View> + </View> + )} + {error ? ( + <ErrorMessage message={error} style={styles.error} /> + ) : undefined} + </View> + <View style={[styles.btnContainer]}> + {isProcessing ? ( + <View style={styles.btn}> + <ActivityIndicator color="#fff" /> + </View> + ) : ( + <View style={{gap: 6}}> + {stage === Stages.RequestCode && ( + <Button + testID="requestChangeBtn" + type="primary" + onPress={onRequestCode} + accessibilityLabel={_(msg`Request Code`)} + accessibilityHint="" + label={_(msg`Request Code`)} + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + {stage === Stages.ChangePassword && ( + <Button + testID="confirmBtn" + type="primary" + onPress={onChangePassword} + accessibilityLabel={_(msg`Next`)} + accessibilityHint="" + label={_(msg`Next`)} + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + <Button + testID="cancelBtn" + type={stage !== Stages.Done ? 'default' : 'primary'} + onPress={() => { + closeModal() + }} + accessibilityLabel={ + stage !== Stages.Done ? _(msg`Cancel`) : _(msg`Close`) + } + accessibilityHint="" + label={stage !== Stages.Done ? _(msg`Cancel`) : _(msg`Close`)} + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + </View> + )} + </View> + </ScrollView> + </SafeAreaView> + ) +} + +const styles = StyleSheet.create({ + container: { + justifyContent: 'space-between', + }, + containerMobile: { + paddingHorizontal: 18, + paddingBottom: 35, + }, + titleSection: { + paddingTop: isWeb ? 0 : 4, + paddingBottom: isWeb ? 14 : 10, + }, + title: { + textAlign: 'center', + fontWeight: '600', + marginBottom: 5, + }, + error: { + borderRadius: 6, + }, + textInput: { + width: '100%', + paddingHorizontal: 14, + paddingVertical: 10, + fontSize: 16, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 32, + padding: 14, + backgroundColor: colors.blue3, + }, + btnContainer: { + paddingTop: 20, + }, + group: { + borderWidth: 1, + borderRadius: 10, + marginVertical: 20, + }, + groupLabel: { + paddingHorizontal: 20, + paddingBottom: 5, + }, + groupContent: { + flexDirection: 'row', + alignItems: 'center', + }, + groupBottom: { + borderTopWidth: 1, + }, + groupContentIcon: { + marginLeft: 10, + }, +}) diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index 945d7bc89..40d78cfe0 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -1,11 +1,12 @@ import React from 'react' import { + SafeAreaView, ActivityIndicator, StyleSheet, TouchableOpacity, View, } from 'react-native' -import {TextInput} from './util' +import {TextInput, ScrollView} from './util' import LinearGradient from 'react-native-linear-gradient' import * as Toast from '../util/Toast' import {Text} from '../util/text/Text' @@ -20,8 +21,9 @@ import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useModalControls} from '#/state/modals' import {useSession, useSessionApi, getAgent} from '#/state/session' +import {isAndroid} from 'platform/detection' -export const snapPoints = ['60%'] +export const snapPoints = isAndroid ? ['90%'] : ['55%'] export function Component({}: {}) { const pal = usePalette('default') @@ -76,8 +78,10 @@ export function Component({}: {}) { closeModal() } return ( - <View style={[styles.container, pal.view]}> - <View style={[styles.innerContainer, pal.view]}> + <SafeAreaView style={[s.flex1]}> + <ScrollView + contentContainerStyle={[pal.view]} + keyboardShouldPersistTaps="handled"> <View style={[styles.titleContainer, pal.view]}> <Text type="title-xl" style={[s.textCenter, pal.text]}> <Trans>Delete Account</Trans> @@ -234,18 +238,12 @@ export function Component({}: {}) { )} </> )} - </View> - </View> + </ScrollView> + </SafeAreaView> ) } const styles = StyleSheet.create({ - container: { - flex: 1, - }, - innerContainer: { - paddingBottom: 20, - }, titleContainer: { display: 'flex', flexDirection: 'row', diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 7f814d971..4aa10d75b 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -36,6 +36,7 @@ import * as ModerationDetailsModal from './ModerationDetails' import * as BirthDateSettingsModal from './BirthDateSettings' import * as VerifyEmailModal from './VerifyEmail' import * as ChangeEmailModal from './ChangeEmail' +import * as ChangePasswordModal from './ChangePassword' import * as SwitchAccountModal from './SwitchAccount' import * as LinkWarningModal from './LinkWarning' import * as EmbedConsentModal from './EmbedConsent' @@ -172,6 +173,9 @@ export function ModalsContainer() { } else if (activeModal?.name === 'change-email') { snapPoints = ChangeEmailModal.snapPoints element = <ChangeEmailModal.Component /> + } else if (activeModal?.name === 'change-password') { + snapPoints = ChangePasswordModal.snapPoints + element = <ChangePasswordModal.Component /> } else if (activeModal?.name === 'switch-account') { snapPoints = SwitchAccountModal.snapPoints element = <SwitchAccountModal.Component /> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index d79663746..384a4772a 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -34,6 +34,7 @@ import * as ModerationDetailsModal from './ModerationDetails' import * as BirthDateSettingsModal from './BirthDateSettings' import * as VerifyEmailModal from './VerifyEmail' import * as ChangeEmailModal from './ChangeEmail' +import * as ChangePasswordModal from './ChangePassword' import * as LinkWarningModal from './LinkWarning' import * as EmbedConsentModal from './EmbedConsent' @@ -134,6 +135,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <VerifyEmailModal.Component {...modal} /> } else if (modal.name === 'change-email') { element = <ChangeEmailModal.Component /> + } else if (modal.name === 'change-password') { + element = <ChangePasswordModal.Component /> } else if (modal.name === 'link-warning') { element = <LinkWarningModal.Component {...modal} /> } else if (modal.name === 'embed-consent') { diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index 834b1c0d0..06ec2e450 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -22,7 +22,6 @@ export interface RenderTabBarFnProps { export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element interface Props { - tabBarPosition?: 'top' | 'bottom' initialPage?: number renderTabBar: RenderTabBarFn onPageSelected?: (index: number) => void @@ -36,7 +35,6 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( function PagerImpl( { children, - tabBarPosition = 'top', initialPage = 0, renderTabBar, onPageScrollStateChanged, @@ -122,11 +120,10 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( return ( <View testID={testID} style={s.flex1}> - {tabBarPosition === 'top' && - renderTabBar({ - selectedPage, - onSelect: onTabBarSelect, - })} + {renderTabBar({ + selectedPage, + onSelect: onTabBarSelect, + })} <AnimatedPagerView ref={pagerView} style={s.flex1} @@ -136,11 +133,6 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( onPageScroll={onPageScroll}> {children} </AnimatedPagerView> - {tabBarPosition === 'bottom' && - renderTabBar({ - selectedPage, - onSelect: onTabBarSelect, - })} </View> ) }, diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx index dde799e42..d7113bb05 100644 --- a/src/view/com/pager/Pager.web.tsx +++ b/src/view/com/pager/Pager.web.tsx @@ -11,7 +11,6 @@ export interface RenderTabBarFnProps { export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element interface Props { - tabBarPosition?: 'top' | 'bottom' initialPage?: number renderTabBar: RenderTabBarFn onPageSelected?: (index: number) => void @@ -20,7 +19,6 @@ interface Props { export const Pager = React.forwardRef(function PagerImpl( { children, - tabBarPosition = 'top', initialPage = 0, renderTabBar, onPageSelected, @@ -72,22 +70,16 @@ export const Pager = React.forwardRef(function PagerImpl( return ( <View style={s.hContentRegion}> - {tabBarPosition === 'top' && - renderTabBar({ - selectedPage, - tabBarAnchor: <View ref={anchorRef} />, - onSelect: onTabBarSelect, - })} + {renderTabBar({ + selectedPage, + tabBarAnchor: <View ref={anchorRef} />, + onSelect: onTabBarSelect, + })} {React.Children.map(children, (child, i) => ( <View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}> {child} </View> ))} - {tabBarPosition === 'bottom' && - renderTabBar({ - selectedPage, - onSelect: onTabBarSelect, - })} </View> ) }) diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 279b607ad..7e9ed24db 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -183,8 +183,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( initialPage={initialPage} onPageSelected={onPageSelectedInner} onPageSelecting={onPageSelecting} - renderTabBar={renderTabBar} - tabBarPosition="top"> + renderTabBar={renderTabBar}> {toArray(children) .filter(Boolean) .map((child, i) => { diff --git a/src/view/com/pager/PagerWithHeader.web.tsx b/src/view/com/pager/PagerWithHeader.web.tsx index 0a18a9e7d..4f959d548 100644 --- a/src/view/com/pager/PagerWithHeader.web.tsx +++ b/src/view/com/pager/PagerWithHeader.web.tsx @@ -76,8 +76,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( initialPage={initialPage} onPageSelected={onPageSelectedInner} onPageSelecting={onPageSelecting} - renderTabBar={renderTabBar} - tabBarPosition="top"> + renderTabBar={renderTabBar}> {toArray(children) .filter(Boolean) .map((child, i) => { diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index a517ba430..afbdeb8f4 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -31,6 +31,7 @@ import {PressableWithHover} from './PressableWithHover' import FixedTouchableHighlight from '../pager/FixedTouchableHighlight' import {useModalControls} from '#/state/modals' import {useOpenLink} from '#/state/preferences/in-app-browser' +import {WebAuxClickWrapper} from 'view/com/util/WebAuxClickWrapper' type Event = | React.MouseEvent<HTMLAnchorElement, MouseEvent> @@ -104,17 +105,19 @@ export const Link = memo(function Link({ ) } return ( - <TouchableWithoutFeedback - testID={testID} - onPress={onPress} - accessible={accessible} - accessibilityRole="link" - {...props}> - {/* @ts-ignore web only -prf */} - <View style={style} href={anchorHref}> - {children ? children : <Text>{title || 'link'}</Text>} - </View> - </TouchableWithoutFeedback> + <WebAuxClickWrapper> + <TouchableWithoutFeedback + testID={testID} + onPress={onPress} + accessible={accessible} + accessibilityRole="link" + {...props}> + {/* @ts-ignore web only -prf */} + <View style={style} href={anchorHref}> + {children ? children : <Text>{title || 'link'}</Text>} + </View> + </TouchableWithoutFeedback> + </WebAuxClickWrapper> ) } diff --git a/src/view/com/util/WebAuxClickWrapper.tsx b/src/view/com/util/WebAuxClickWrapper.tsx new file mode 100644 index 000000000..8105a8518 --- /dev/null +++ b/src/view/com/util/WebAuxClickWrapper.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import {Platform} from 'react-native' + +const onMouseUp = (e: React.MouseEvent & {target: HTMLElement}) => { + // Only handle whenever it is the middle button + if (e.button !== 1 || e.target.closest('a') || e.target.tagName === 'A') { + return + } + + e.target.dispatchEvent( + new MouseEvent('click', {metaKey: true, bubbles: true}), + ) +} + +const onMouseDown = (e: React.MouseEvent) => { + // Prevents the middle click scroll from enabling + if (e.button !== 1) return + e.preventDefault() +} + +export function WebAuxClickWrapper({children}: React.PropsWithChildren<{}>) { + if (Platform.OS !== 'web') return children + + return ( + // @ts-ignore web only + <div onMouseDown={onMouseDown} onMouseUp={onMouseUp}> + {children} + </div> + ) +} diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index 8f24f8288..e6e05bb04 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -9,15 +9,13 @@ import { PressableStateCallbackType, ActivityIndicator, View, + NativeSyntheticEvent, + NativeTouchEvent, } from 'react-native' import {Text} from '../text/Text' import {useTheme} from 'lib/ThemeContext' import {choose} from 'lib/functions' -type Event = - | React.MouseEvent<HTMLAnchorElement, MouseEvent> - | GestureResponderEvent - export type ButtonType = | 'primary' | 'secondary' @@ -59,7 +57,7 @@ export function Button({ style?: StyleProp<ViewStyle> labelContainerStyle?: StyleProp<ViewStyle> labelStyle?: StyleProp<TextStyle> - onPress?: () => void | Promise<void> + onPress?: (e: NativeSyntheticEvent<NativeTouchEvent>) => void | Promise<void> testID?: string accessibilityLabel?: string accessibilityHint?: string @@ -148,11 +146,11 @@ export function Button({ const [isLoading, setIsLoading] = React.useState(false) const onPressWrapped = React.useCallback( - async (event: Event) => { + async (event: GestureResponderEvent) => { event.stopPropagation() event.preventDefault() withLoading && setIsLoading(true) - await onPress?.() + await onPress?.(event) withLoading && setIsLoading(false) }, [onPress, withLoading], diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index 411b77484..2285b0615 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -1,10 +1,12 @@ import React, {PropsWithChildren, useMemo, useRef} from 'react' import { Dimensions, + GestureResponderEvent, StyleProp, StyleSheet, TouchableOpacity, TouchableWithoutFeedback, + useWindowDimensions, View, ViewStyle, } from 'react-native' @@ -19,6 +21,7 @@ import {useTheme} from 'lib/ThemeContext' import {HITSLOP_10} from 'lib/constants' import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' +import {isWeb} from 'platform/detection' const ESTIMATED_BTN_HEIGHT = 50 const ESTIMATED_SEP_HEIGHT = 16 @@ -80,21 +83,22 @@ export function DropdownButton({ const ref1 = useRef<TouchableOpacity>(null) const ref2 = useRef<View>(null) - const onPress = () => { + const onPress = (e: GestureResponderEvent) => { const ref = ref1.current || ref2.current + const {height: winHeight} = Dimensions.get('window') + const pressY = e.nativeEvent.pageY ref?.measure( ( _x: number, _y: number, width: number, - height: number, + _height: number, pageX: number, pageY: number, ) => { if (!menuWidth) { menuWidth = 200 } - const winHeight = Dimensions.get('window').height let estimatedMenuHeight = 0 for (const item of items) { if (item && isSep(item)) { @@ -108,13 +112,16 @@ export function DropdownButton({ const newX = openToRight ? pageX + width + rightOffset : pageX + width - menuWidth - let newY = pageY + height + bottomOffset + + // Add a bit of additional room + let newY = pressY + bottomOffset + 20 if (openUpwards || newY + estimatedMenuHeight > winHeight) { newY -= estimatedMenuHeight } createDropdownMenu( newX, newY, + pageY, menuWidth, items.filter(v => !!v) as DropdownItem[], ) @@ -168,6 +175,7 @@ export function DropdownButton({ function createDropdownMenu( x: number, y: number, + pageY: number, width: number, items: DropdownItem[], ): RootSiblings { @@ -185,6 +193,7 @@ function createDropdownMenu( onOuterPress={onOuterPress} x={x} y={y} + pageY={pageY} width={width} items={items} onPressItem={onPressItem} @@ -198,6 +207,7 @@ type DropDownItemProps = { onOuterPress: () => void x: number y: number + pageY: number width: number items: DropdownItem[] onPressItem: (index: number) => void @@ -207,6 +217,7 @@ const DropdownItems = ({ onOuterPress, x, y, + pageY, width, items, onPressItem, @@ -214,6 +225,7 @@ const DropdownItems = ({ const pal = usePalette('default') const theme = useTheme() const {_} = useLingui() + const {height: screenHeight} = useWindowDimensions() const dropDownBackgroundColor = theme.colorScheme === 'dark' ? pal.btn : pal.view const separatorColor = @@ -233,7 +245,21 @@ const DropdownItems = ({ onPress={onOuterPress} accessibilityLabel={_(msg`Toggle dropdown`)} accessibilityHint=""> - <View style={[styles.bg]} /> + <View + style={[ + styles.bg, + // On web we need to adjust the top and bottom relative to the scroll position + isWeb + ? { + top: -pageY, + bottom: pageY - screenHeight, + } + : { + top: 0, + bottom: 0, + }, + ]} + /> </TouchableWithoutFeedback> <View style={[ @@ -295,10 +321,8 @@ function isBtn(item: DropdownItem): item is DropdownItemButton { const styles = StyleSheet.create({ bg: { position: 'absolute', - top: 0, - right: 0, - bottom: 0, left: 0, + width: '100%', backgroundColor: '#000', opacity: 0.1, }, diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx index 8b0858b69..d556e7669 100644 --- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx @@ -78,9 +78,13 @@ function Player({ onLoad: () => void }) { // ensures we only load what's requested + // when it's a youtube video, we need to allow both bsky.app and youtube.com const onShouldStartLoadWithRequest = React.useCallback( - (event: ShouldStartLoadRequest) => event.url === params.playerUri, - [params.playerUri], + (event: ShouldStartLoadRequest) => + event.url === params.playerUri || + (params.source.startsWith('youtube') && + event.url.includes('www.youtube.com')), + [params.playerUri, params.source], ) // Don't show the player until it is active diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index 256817bba..d9d84feb4 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -113,13 +113,15 @@ export function QuoteEmbed({ hoverStyle={{borderColor: pal.colors.borderLinkHover}} href={itemHref} title={itemTitle}> - <PostMeta - author={quote.author} - showAvatar - authorHasWarning={false} - postHref={itemHref} - timestamp={quote.indexedAt} - /> + <View pointerEvents="none"> + <PostMeta + author={quote.author} + showAvatar + authorHasWarning={false} + postHref={itemHref} + timestamp={quote.indexedAt} + /> + </View> {moderation ? ( <PostAlerts moderation={moderation} style={styles.alert} /> ) : null} diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 6f168a293..7e235babb 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useCallback} from 'react' import { StyleSheet, StyleProp, @@ -29,6 +29,8 @@ import {ListEmbed} from './ListEmbed' import {isCauseALabelOnUri, isQuoteBlurred} from 'lib/moderation' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' import {ContentHider} from '../moderation/ContentHider' +import {isNative} from '#/platform/detection' +import {shareUrl} from '#/lib/sharing' type Embed = | AppBskyEmbedRecord.View @@ -51,6 +53,16 @@ export function PostEmbeds({ const pal = usePalette('default') const {openLightbox} = useLightboxControls() + const externalUri = AppBskyEmbedExternal.isView(embed) + ? embed.external.uri + : null + + const onShareExternal = useCallback(() => { + if (externalUri && isNative) { + shareUrl(externalUri) + } + }, [externalUri]) + // quote post with media // = if (AppBskyEmbedRecordWithMedia.isView(embed)) { @@ -164,7 +176,8 @@ export function PostEmbeds({ anchorNoUnderline href={link.uri} style={[styles.extOuter, pal.view, pal.borderDark, style]} - hoverStyle={{borderColor: pal.colors.borderLinkHover}}> + hoverStyle={{borderColor: pal.colors.borderLinkHover}} + onLongPress={onShareExternal}> <ExternalLinkEmbed link={link} /> </Link> ) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 7d6a40f02..1da276488 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -184,8 +184,7 @@ function HomeScreenReady({ initialPage={clamp(selectedPageIndex, 0, customFeeds.length)} onPageSelected={onPageSelected} onPageScrollStateChanged={onPageScrollStateChanged} - renderTabBar={renderTabBar} - tabBarPosition="top"> + renderTabBar={renderTabBar}> <FeedPage key="1" testID="followingFeedPage" @@ -212,8 +211,7 @@ function HomeScreenReady({ testID="homeScreen" onPageSelected={onPageSelected} onPageScrollStateChanged={onPageScrollStateChanged} - renderTabBar={renderTabBar} - tabBarPosition="top"> + renderTabBar={renderTabBar}> <HomeLoggedOutCTA /> </Pager> ) diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 17c93b037..796464883 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -55,6 +55,7 @@ import { usePreferencesQuery, usePinFeedMutation, useUnpinFeedMutation, + useSetSaveFeedsMutation, } from '#/state/queries/preferences' import {logger} from '#/logger' import {useAnalytics} from '#/lib/analytics/analytics' @@ -246,9 +247,11 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { useUnpinFeedMutation() const isPending = isPinPending || isUnpinPending const {data: preferences} = usePreferencesQuery() + const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() const {track} = useAnalytics() const isPinned = preferences?.feeds?.pinned?.includes(list.uri) + const isSaved = preferences?.feeds?.saved?.includes(list.uri) const onTogglePinned = React.useCallback(async () => { Haptics.default() @@ -361,6 +364,16 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { message: _(msg`Are you sure?`), async onPressConfirm() { await listDeleteMutation.mutateAsync({uri: list.uri}) + + if (isSaved || isPinned) { + const {saved, pinned} = preferences!.feeds + + setSavedFeeds({ + saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved, + pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned, + }) + } + Toast.show(_(msg`List deleted`)) track('Lists:Delete') if (navigation.canGoBack()) { @@ -370,7 +383,18 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { } }, }) - }, [openModal, list, listDeleteMutation, navigation, track, _]) + }, [ + openModal, + list, + listDeleteMutation, + navigation, + track, + _, + preferences, + isPinned, + isSaved, + setSavedFeeds, + ]) const onPressReport = useCallback(() => { openModal({ diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index 4703899a2..142726701 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -190,7 +190,13 @@ type SearchResultSlice = function SearchScreenPostResults({query}: {query: string}) { const {_} = useLingui() + const {currentAccount} = useSession() const [isPTR, setIsPTR] = React.useState(false) + + const augmentedQuery = React.useMemo(() => { + return augmentSearchQuery(query || '', {did: currentAccount?.did}) + }, [query, currentAccount]) + const { isFetched, data: results, @@ -200,7 +206,7 @@ function SearchScreenPostResults({query}: {query: string}) { fetchNextPage, isFetchingNextPage, hasNextPage, - } = useSearchPostsQuery({query}) + } = useSearchPostsQuery({query: augmentedQuery}) const onPullToRefresh = React.useCallback(async () => { setIsPTR(true) @@ -319,13 +325,9 @@ export function SearchScreenInner({ const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() - const {hasSession, currentAccount} = useSession() + const {hasSession} = useSession() const {isDesktop} = useWebMediaQueries() - const augmentedQuery = React.useMemo(() => { - return augmentSearchQuery(query || '', {did: currentAccount?.did}) - }, [query, currentAccount]) - const onPageSelected = React.useCallback( (index: number) => { setMinimalShellMode(false) @@ -337,7 +339,6 @@ export function SearchScreenInner({ if (hasSession) { return query ? ( <Pager - tabBarPosition="top" onPageSelected={onPageSelected} renderTabBar={props => ( <CenteredView @@ -348,7 +349,7 @@ export function SearchScreenInner({ )} initialPage={0}> <View> - <SearchScreenPostResults query={augmentedQuery} /> + <SearchScreenPostResults query={query} /> </View> <View> <SearchScreenUserResults query={query} /> @@ -380,7 +381,6 @@ export function SearchScreenInner({ return query ? ( <Pager - tabBarPosition="top" onPageSelected={onPageSelected} renderTabBar={props => ( <CenteredView diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 3b50c5449..104506576 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -40,8 +40,8 @@ import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' import {useModalControls} from '#/state/modals' import { useSetMinimalShellMode, - useColorMode, - useSetColorMode, + useThemePrefs, + useSetThemePrefs, useOnboardingDispatch, } from '#/state/shell' import { @@ -144,8 +144,8 @@ function SettingsAccountCard({account}: {account: SessionAccount}) { type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> export function SettingsScreen({}: Props) { const queryClient = useQueryClient() - const colorMode = useColorMode() - const setColorMode = useSetColorMode() + const {colorMode, darkTheme} = useThemePrefs() + const {setColorMode, setDarkTheme} = useSetThemePrefs() const pal = usePalette('default') const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() @@ -483,8 +483,36 @@ export function SettingsScreen({}: Props) { /> </View> </View> + <View style={styles.spacer20} /> + {colorMode !== 'light' && ( + <> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Dark Theme</Trans> + </Text> + <View> + <View style={[styles.linkCard, pal.view, styles.selectableBtns]}> + <SelectableBtn + selected={!darkTheme || darkTheme === 'dim'} + label={_(msg`Dim`)} + left + onSelect={() => setDarkTheme('dim')} + accessibilityHint={_(msg`Set dark theme to the dim theme`)} + /> + <SelectableBtn + selected={darkTheme === 'dark'} + label={_(msg`Dark`)} + right + onSelect={() => setDarkTheme('dark')} + accessibilityHint={_(msg`Set dark theme to the dark theme`)} + /> + </View> + </View> + <View style={styles.spacer20} /> + </> + )} + <Text type="xl-bold" style={[pal.text, styles.heading]}> <Trans>Basics</Trans> </Text> @@ -647,7 +675,7 @@ export function SettingsScreen({}: Props) { /> </View> <Text type="lg" style={pal.text}> - <Trans>App passwords</Trans> + <Trans>App Passwords</Trans> </Text> </TouchableOpacity> <TouchableOpacity @@ -668,7 +696,7 @@ export function SettingsScreen({}: Props) { /> </View> <Text type="lg" style={pal.text} numberOfLines={1}> - <Trans>Change handle</Trans> + <Trans>Change Handle</Trans> </Text> </TouchableOpacity> {isNative && ( @@ -684,9 +712,30 @@ export function SettingsScreen({}: Props) { )} <View style={styles.spacer20} /> <Text type="xl-bold" style={[pal.text, styles.heading]}> - <Trans>Danger Zone</Trans> + <Trans>Account</Trans> </Text> <TouchableOpacity + testID="changePasswordBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={() => openModal({name: 'change-password'})} + accessibilityRole="button" + accessibilityLabel={_(msg`Change password`)} + accessibilityHint={_(msg`Change your Bluesky password`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="lock" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text} numberOfLines={1}> + <Trans>Change Password</Trans> + </Text> + </TouchableOpacity> + <TouchableOpacity style={[pal.view, styles.linkCard]} onPress={onPressDeleteAccount} accessible={true} @@ -703,7 +752,7 @@ export function SettingsScreen({}: Props) { /> </View> <Text type="lg" style={dangerText}> - <Trans>Delete my account…</Trans> + <Trans>Delete My Account…</Trans> </Text> </TouchableOpacity> <View style={styles.spacer20} /> diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index d8898f20e..40929555e 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -3,7 +3,7 @@ import {View} from 'react-native' import {CenteredView, ScrollView} from '#/view/com/util/Views' import {atoms as a, useTheme, ThemeProvider} from '#/alf' -import {useSetColorMode} from '#/state/shell' +import {useSetThemePrefs} from '#/state/shell' import {Button} from '#/components/Button' import {Theming} from './Theming' @@ -19,7 +19,7 @@ import {Icons} from './Icons' export function Storybook() { const t = useTheme() - const setColorMode = useSetColorMode() + const {setColorMode, setDarkTheme} = useSetThemePrefs() return ( <ScrollView> @@ -38,7 +38,7 @@ export function Storybook() { variant="solid" color="secondary" size="small" - label='Set theme to "system"' + label='Set theme to "light"' onPress={() => setColorMode('light')}> Light </Button> @@ -46,8 +46,22 @@ export function Storybook() { variant="solid" color="secondary" size="small" - label='Set theme to "system"' - onPress={() => setColorMode('dark')}> + label='Set theme to "dim"' + onPress={() => { + setColorMode('dark') + setDarkTheme('dim') + }}> + Dim + </Button> + <Button + variant="solid" + color="secondary" + size="small" + label='Set theme to "dark"' + onPress={() => { + setColorMode('dark') + setDarkTheme('dark') + }}> Dark </Button> </View> diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 5320aebfc..6b0cc6808 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -52,6 +52,8 @@ function ShellInner() { const canGoBack = useNavigationState(state => !isStateAtTabRoot(state)) const {hasSession, currentAccount} = useSession() const closeAnyActiveElement = useCloseAnyActiveElement() + // start undefined + const currentAccountDid = React.useRef<string | undefined>(undefined) React.useEffect(() => { let listener = {remove() {}} @@ -66,13 +68,10 @@ function ShellInner() { }, [closeAnyActiveElement]) React.useEffect(() => { - if (currentAccount) { + // only runs when did changes + if (currentAccount && currentAccountDid.current !== currentAccount.did) { + currentAccountDid.current = currentAccount.did notifications.requestPermissionsAndRegisterToken(currentAccount) - } - }, [currentAccount]) - - React.useEffect(() => { - if (currentAccount) { const unsub = notifications.registerTokenChangeHandler(currentAccount) return unsub } diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 76f4f5c9b..97c065502 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -11,7 +11,6 @@ import {DrawerContent} from './Drawer' import {useWebMediaQueries} from '../../lib/hooks/useWebMediaQueries' 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' @@ -26,7 +25,6 @@ function ShellInner() { const closeAllActiveElements = useCloseAllActiveElements() useWebBodyScrollLock(isDrawerOpen) - useAuxClick() useEffect(() => { const unsubscribe = navigator.addListener('state', () => { |