diff options
Diffstat (limited to 'src/screens/Settings')
-rw-r--r-- | src/screens/Settings/AccountSettings.tsx | 5 | ||||
-rw-r--r-- | src/screens/Settings/ThreadPreferences.tsx | 158 | ||||
-rw-r--r-- | src/screens/Settings/components/ChangeHandleDialog.tsx | 16 | ||||
-rw-r--r-- | src/screens/Settings/components/ChangePasswordDialog.tsx | 300 |
4 files changed, 317 insertions, 162 deletions
diff --git a/src/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx index 86652d277..8f320459c 100644 --- a/src/screens/Settings/AccountSettings.tsx +++ b/src/screens/Settings/AccountSettings.tsx @@ -25,6 +25,7 @@ import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/ic import {Trash_Stroke2_Corner2_Rounded} from '#/components/icons/Trash' import * as Layout from '#/components/Layout' import {ChangeHandleDialog} from './components/ChangeHandleDialog' +import {ChangePasswordDialog} from './components/ChangePasswordDialog' import {DeactivateAccountDialog} from './components/DeactivateAccountDialog' import {ExportCarDialog} from './components/ExportCarDialog' @@ -37,6 +38,7 @@ export function AccountSettingsScreen({}: Props) { const emailDialogControl = useEmailDialogControl() const birthdayControl = useDialogControl() const changeHandleControl = useDialogControl() + const changePasswordControl = useDialogControl() const exportCarControl = useDialogControl() const deactivateAccountControl = useDialogControl() @@ -117,7 +119,7 @@ export function AccountSettingsScreen({}: Props) { <SettingsList.Divider /> <SettingsList.PressableItem label={_(msg`Password`)} - onPress={() => openModal({name: 'change-password'})}> + onPress={() => changePasswordControl.open()}> <SettingsList.ItemIcon icon={LockIcon} /> <SettingsList.ItemText> <Trans>Password</Trans> @@ -180,6 +182,7 @@ export function AccountSettingsScreen({}: Props) { <BirthDateSettingsDialog control={birthdayControl} /> <ChangeHandleDialog control={changeHandleControl} /> + <ChangePasswordDialog control={changePasswordControl} /> <ExportCarDialog control={exportCarControl} /> <DeactivateAccountDialog control={deactivateAccountControl} /> </Layout.Screen> diff --git a/src/screens/Settings/ThreadPreferences.tsx b/src/screens/Settings/ThreadPreferences.tsx index af3cf915f..cba896a76 100644 --- a/src/screens/Settings/ThreadPreferences.tsx +++ b/src/screens/Settings/ThreadPreferences.tsx @@ -6,11 +6,6 @@ import { type CommonNavigatorParams, type NativeStackScreenProps, } from '#/lib/routes/types' -import {useGate} from '#/lib/statsig/statsig' -import { - usePreferencesQuery, - useSetThreadViewPreferencesMutation, -} from '#/state/queries/preferences' import { normalizeSort, normalizeView, @@ -18,7 +13,6 @@ import { } from '#/state/queries/preferences/useThreadPreferences' import {atoms as a, useTheme} from '#/alf' import * as Toggle from '#/components/forms/Toggle' -import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker' import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble' import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' import {Tree_Stroke2_Corner0_Rounded as TreeIcon} from '#/components/icons/Tree' @@ -28,16 +22,6 @@ import * as SettingsList from './components/SettingsList' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'> export function ThreadPreferencesScreen({}: Props) { - const gate = useGate() - - return gate('post_threads_v2_unspecced') ? ( - <ThreadPreferencesV2 /> - ) : ( - <ThreadPreferencesV1 /> - ) -} - -export function ThreadPreferencesV2() { const t = useTheme() const {_} = useLingui() const { @@ -150,145 +134,3 @@ export function ThreadPreferencesV2() { </Layout.Screen> ) } - -export function ThreadPreferencesV1() { - const {_} = useLingui() - const t = useTheme() - - const {data: preferences} = usePreferencesQuery() - const {mutate: setThreadViewPrefs, variables} = - useSetThreadViewPreferencesMutation() - - const sortReplies = variables?.sort ?? preferences?.threadViewPrefs?.sort - - const prioritizeFollowedUsers = Boolean( - variables?.prioritizeFollowedUsers ?? - preferences?.threadViewPrefs?.prioritizeFollowedUsers, - ) - const treeViewEnabled = Boolean( - variables?.lab_treeViewEnabled ?? - preferences?.threadViewPrefs?.lab_treeViewEnabled, - ) - - return ( - <Layout.Screen testID="threadPreferencesScreen"> - <Layout.Header.Outer> - <Layout.Header.BackButton /> - <Layout.Header.Content> - <Layout.Header.TitleText> - <Trans>Thread Preferences</Trans> - </Layout.Header.TitleText> - </Layout.Header.Content> - <Layout.Header.Slot /> - </Layout.Header.Outer> - <Layout.Content> - <SettingsList.Container> - <SettingsList.Group> - <SettingsList.ItemIcon icon={BubblesIcon} /> - <SettingsList.ItemText> - <Trans>Sort replies</Trans> - </SettingsList.ItemText> - <View style={[a.w_full, a.gap_md]}> - <Text style={[a.flex_1, t.atoms.text_contrast_medium]}> - <Trans>Sort replies to the same post by:</Trans> - </Text> - <Toggle.Group - label={_(msg`Sort replies by`)} - type="radio" - values={sortReplies ? [sortReplies] : []} - onChange={values => setThreadViewPrefs({sort: values[0]})}> - <View style={[a.gap_sm, a.flex_1]}> - <Toggle.Item name="hotness" label={_(msg`Hot replies first`)}> - <Toggle.Radio /> - <Toggle.LabelText> - <Trans>Hot replies first</Trans> - </Toggle.LabelText> - </Toggle.Item> - <Toggle.Item - name="oldest" - label={_(msg`Oldest replies first`)}> - <Toggle.Radio /> - <Toggle.LabelText> - <Trans>Oldest replies first</Trans> - </Toggle.LabelText> - </Toggle.Item> - <Toggle.Item - name="newest" - label={_(msg`Newest replies first`)}> - <Toggle.Radio /> - <Toggle.LabelText> - <Trans>Newest replies first</Trans> - </Toggle.LabelText> - </Toggle.Item> - <Toggle.Item - name="most-likes" - label={_(msg`Most-liked replies first`)}> - <Toggle.Radio /> - <Toggle.LabelText> - <Trans>Most-liked first</Trans> - </Toggle.LabelText> - </Toggle.Item> - <Toggle.Item - name="random" - label={_(msg`Random (aka "Poster's Roulette")`)}> - <Toggle.Radio /> - <Toggle.LabelText> - <Trans>Random (aka "Poster's Roulette")</Trans> - </Toggle.LabelText> - </Toggle.Item> - </View> - </Toggle.Group> - </View> - </SettingsList.Group> - <SettingsList.Group> - <SettingsList.ItemIcon icon={PersonGroupIcon} /> - <SettingsList.ItemText> - <Trans>Prioritize your Follows</Trans> - </SettingsList.ItemText> - <Toggle.Item - type="checkbox" - name="prioritize-follows" - label={_(msg`Prioritize your Follows`)} - value={prioritizeFollowedUsers} - onChange={value => - setThreadViewPrefs({ - prioritizeFollowedUsers: value, - }) - } - style={[a.w_full, a.gap_md]}> - <Toggle.LabelText style={[a.flex_1]}> - <Trans> - Show replies by people you follow before all other replies - </Trans> - </Toggle.LabelText> - <Toggle.Platform /> - </Toggle.Item> - </SettingsList.Group> - <SettingsList.Divider /> - <SettingsList.Group> - <SettingsList.ItemIcon icon={BeakerIcon} /> - <SettingsList.ItemText> - <Trans>Experimental</Trans> - </SettingsList.ItemText> - <Toggle.Item - type="checkbox" - name="threaded-mode" - label={_(msg`Threaded mode`)} - value={treeViewEnabled} - onChange={value => - setThreadViewPrefs({ - lab_treeViewEnabled: value, - }) - } - style={[a.w_full, a.gap_md]}> - <Toggle.LabelText style={[a.flex_1]}> - <Trans>Show replies as threaded</Trans> - </Toggle.LabelText> - <Toggle.Platform /> - </Toggle.Item> - </SettingsList.Group> - </SettingsList.Container> - </Layout.Content> - </Layout.Screen> - ) -} diff --git a/src/screens/Settings/components/ChangeHandleDialog.tsx b/src/screens/Settings/components/ChangeHandleDialog.tsx index 59e004252..8002c172f 100644 --- a/src/screens/Settings/components/ChangeHandleDialog.tsx +++ b/src/screens/Settings/components/ChangeHandleDialog.tsx @@ -209,9 +209,14 @@ function ProvidedHandlePage({ You are verified. You will lose your verification status if you change your handle.{' '} <InlineLinkText - label={_(msg`Learn more`)} + label={_( + msg({ + message: `Learn more`, + context: `english-only-resource`, + }), + )} to={urls.website.blog.initialVerificationAnnouncement}> - <Trans>Learn more.</Trans> + <Trans context="english-only-resource">Learn more.</Trans> </InlineLinkText> </Trans> </Admonition> @@ -268,7 +273,12 @@ function ProvidedHandlePage({ If you have your own domain, you can use that as your handle. This lets you self-verify your identity.{' '} <InlineLinkText - label={_(msg`learn more`)} + label={_( + msg({ + message: `Learn more`, + context: `english-only-resource`, + }), + )} to="https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial" style={[a.font_bold]} disableMismatchWarning> diff --git a/src/screens/Settings/components/ChangePasswordDialog.tsx b/src/screens/Settings/components/ChangePasswordDialog.tsx new file mode 100644 index 000000000..7e3e62eee --- /dev/null +++ b/src/screens/Settings/components/ChangePasswordDialog.tsx @@ -0,0 +1,300 @@ +import {useState} from 'react' +import {useWindowDimensions, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import * as EmailValidator from 'email-validator' + +import {cleanError, isNetworkError} from '#/lib/strings/errors' +import {checkAndFormatResetCode} from '#/lib/strings/password' +import {logger} from '#/logger' +import {isNative} from '#/platform/detection' +import {useAgent, useSession} from '#/state/session' +import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' +import {android, atoms as a, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +enum Stages { + RequestCode = 'RequestCode', + ChangePassword = 'ChangePassword', + Done = 'Done', +} + +export function ChangePasswordDialog({ + control, +}: { + control: Dialog.DialogControlProps +}) { + const {height} = useWindowDimensions() + + return ( + <Dialog.Outer + control={control} + nativeOptions={android({minHeight: height / 2})}> + <Dialog.Handle /> + <Inner /> + </Dialog.Outer> + ) +} + +function Inner() { + const {_} = useLingui() + const {currentAccount} = useSession() + const agent = useAgent() + const control = Dialog.useDialogContext() + + const [stage, setStage] = useState(Stages.RequestCode) + const [isProcessing, setIsProcessing] = useState(false) + const [resetCode, setResetCode] = useState('') + const [newPassword, setNewPassword] = useState('') + const [error, setError] = useState('') + + const uiStrings = { + RequestCode: { + title: _(msg`Change your password`), + message: _( + msg`If you want to change your password, we will send you a code to verify that this is your account.`, + ), + }, + ChangePassword: { + title: _(msg`Enter code`), + message: _( + msg`Please enter the code you received and the new password you would like to use.`, + ), + }, + Done: { + title: _(msg`Password changed`), + message: _( + msg`Your password has been changed successfully! Please use your new password when you sign in to Bluesky from now on.`, + ), + }, + } + + 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) { + if (isNetworkError(e)) { + setError( + _( + msg`Unable to contact your service. Please check your internet connection and try again.`, + ), + ) + } else { + logger.error('Failed to request password reset', {safeMessage: e}) + setError(cleanError(e)) + } + } finally { + setIsProcessing(false) + } + } + + const onChangePassword = async () => { + const formattedCode = checkAndFormatResetCode(resetCode) + if (!formattedCode) { + setError( + _( + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, + ), + ) + return + } + if (!newPassword) { + setError( + _(msg`Please enter a password. It must be at least 8 characters long.`), + ) + return + } + if (newPassword.length < 8) { + setError(_(msg`Password must be at least 8 characters long.`)) + return + } + + setError('') + setIsProcessing(true) + try { + await agent.com.atproto.server.resetPassword({ + token: formattedCode, + password: newPassword, + }) + setStage(Stages.Done) + } catch (e: any) { + if (isNetworkError(e)) { + setError( + _( + msg`Unable to contact your service. Please check your internet connection and try again.`, + ), + ) + } else if (e?.toString().includes('Token is invalid')) { + setError(_(msg`This confirmation code is not valid. Please try again.`)) + } else { + logger.error('Failed to set new password', {safeMessage: e}) + setError(cleanError(e)) + } + } finally { + setIsProcessing(false) + } + } + + const onBlur = () => { + const formattedCode = checkAndFormatResetCode(resetCode) + if (!formattedCode) { + return + } + setResetCode(formattedCode) + } + + return ( + <Dialog.ScrollableInner + label={_(msg`Change password dialog`)} + style={web({maxWidth: 400})}> + <View style={[a.gap_xl]}> + <View style={[a.gap_sm]}> + <Text style={[a.font_heavy, a.text_2xl]}> + {uiStrings[stage].title} + </Text> + {error ? ( + <View style={[a.rounded_sm, a.overflow_hidden]}> + <ErrorMessage message={error} /> + </View> + ) : null} + + <Text style={[a.text_md, a.leading_snug]}> + {uiStrings[stage].message} + </Text> + </View> + + {stage === Stages.ChangePassword && ( + <View style={[a.gap_md]}> + <View> + <TextField.LabelText> + <Trans>Confirmation code</Trans> + </TextField.LabelText> + <TextField.Root> + <TextField.Input + label={_(msg`Confirmation code`)} + placeholder="XXXXX-XXXXX" + value={resetCode} + onChangeText={setResetCode} + onBlur={onBlur} + autoCapitalize="none" + autoCorrect={false} + autoComplete="one-time-code" + /> + </TextField.Root> + </View> + <View> + <TextField.LabelText> + <Trans>New password</Trans> + </TextField.LabelText> + <TextField.Root> + <TextField.Input + label={_(msg`New password`)} + placeholder={_(msg`At least 8 characters`)} + value={newPassword} + onChangeText={setNewPassword} + secureTextEntry + autoCapitalize="none" + autoComplete="new-password" + /> + </TextField.Root> + </View> + </View> + )} + + <View style={[a.gap_sm]}> + {stage === Stages.RequestCode ? ( + <> + <Button + label={_(msg`Request code`)} + color="primary" + size="large" + disabled={isProcessing} + onPress={onRequestCode}> + <ButtonText> + <Trans>Request code</Trans> + </ButtonText> + {isProcessing && <ButtonIcon icon={Loader} />} + </Button> + <Button + label={_(msg`Already have a code?`)} + onPress={() => setStage(Stages.ChangePassword)} + size="large" + color="primary_subtle" + disabled={isProcessing}> + <ButtonText> + <Trans>Already have a code?</Trans> + </ButtonText> + </Button> + {isNative && ( + <Button + label={_(msg`Cancel`)} + color="secondary" + size="large" + disabled={isProcessing} + onPress={() => control.close()}> + <ButtonText> + <Trans>Cancel</Trans> + </ButtonText> + </Button> + )} + </> + ) : stage === Stages.ChangePassword ? ( + <> + <Button + label={_(msg`Change password`)} + color="primary" + size="large" + disabled={isProcessing} + onPress={onChangePassword}> + <ButtonText> + <Trans>Change password</Trans> + </ButtonText> + {isProcessing && <ButtonIcon icon={Loader} />} + </Button> + <Button + label={_(msg`Back`)} + color="secondary" + size="large" + disabled={isProcessing} + onPress={() => { + setResetCode('') + setStage(Stages.RequestCode) + }}> + <ButtonText> + <Trans>Back</Trans> + </ButtonText> + </Button> + </> + ) : stage === Stages.Done ? ( + <Button + label={_(msg`Close`)} + color="primary" + size="large" + onPress={() => control.close()}> + <ButtonText> + <Trans>Close</Trans> + </ButtonText> + </Button> + ) : null} + </View> + </View> + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} |