diff --git a/src/components/Admonition.tsx b/src/components/Admonition.tsx index 140e838e701..8b01a8abaff 100644 --- a/src/components/Admonition.tsx +++ b/src/components/Admonition.tsx @@ -8,7 +8,7 @@ import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf' import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' import {Text as BaseText, TextProps} from '#/components/Typography' -const colors = { +export const colors = { warning: { light: '#DFBC00', dark: '#BFAF1F', diff --git a/src/screens/Settings/AppPasswords.tsx b/src/screens/Settings/AppPasswords.tsx new file mode 100644 index 00000000000..1b8d2857c91 --- /dev/null +++ b/src/screens/Settings/AppPasswords.tsx @@ -0,0 +1,208 @@ +import React, {useCallback} from 'react' +import {View} from 'react-native' +import Animated, { + FadeIn, + FadeOut, + LayoutAnimationConfig, + LinearTransition, + StretchOutY, +} from 'react-native-reanimated' +import {ComAtprotoServerListAppPasswords} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {CommonNavigatorParams} from '#/lib/routes/types' +import {cleanError} from '#/lib/strings/errors' +import {isWeb} from '#/platform/detection' +import { + useAppPasswordDeleteMutation, + useAppPasswordsQuery, +} from '#/state/queries/app-passwords' +import {EmptyState} from '#/view/com/util/EmptyState' +import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useTheme} from '#/alf' +import {Admonition, colors} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import * as Layout from '#/components/Layout' +import {Loader} from '#/components/Loader' +import * as Prompt from '#/components/Prompt' +import {Text} from '#/components/Typography' +import {AddAppPasswordDialog} from './components/AddAppPasswordDialog' +import * as SettingsList from './components/SettingsList' + +type Props = NativeStackScreenProps +export function AppPasswordsScreen({}: Props) { + const {_} = useLingui() + const {data: appPasswords, error} = useAppPasswordsQuery() + const createAppPasswordControl = useDialogControl() + + return ( + + + + {error ? ( + + ) : ( + + + + + Use app passwords to sign in to other Bluesky clients without + giving full access to your account or password. + + + + + + + + + {appPasswords ? ( + appPasswords.length > 0 ? ( + + {appPasswords.map(appPassword => ( + + + + + + ))} + + ) : ( + + ) + ) : ( + + + + )} + + + )} + + + p.name) || []} + /> + + ) +} + +function AppPasswordCard({ + appPassword, +}: { + appPassword: ComAtprotoServerListAppPasswords.AppPassword +}) { + const t = useTheme() + const {i18n, _} = useLingui() + const deleteControl = Prompt.usePromptControl() + const {mutateAsync: deleteMutation} = useAppPasswordDeleteMutation() + + const onDelete = useCallback(async () => { + await deleteMutation({name: appPassword.name}) + Toast.show(_(msg`App password deleted`)) + }, [deleteMutation, appPassword.name, _]) + + return ( + + + + + {appPassword.name} + + + + Created{' '} + {i18n.date(appPassword.createdAt, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} + + + + + + {appPassword.privileged && ( + + + + Allows access to direct messages + + + )} + + + + ) +} diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 4910118f243..85f010e551b 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -60,6 +60,7 @@ export function SettingsScreen({}: Props) { + + + + ) +} + +function CreateDialogInner({passwords}: {passwords: string[]}) { + const control = Dialog.useDialogContext() + const t = useTheme() + const {_} = useLingui() + const autogeneratedName = useRandomName() + const [name, setName] = useState('') + const [privileged, setPrivileged] = useState(false) + const { + mutateAsync: actuallyCreateAppPassword, + error: apiError, + data, + } = useAppPasswordCreateMutation() + + const regexFailError = useMemo( + () => + new DisplayableError( + _( + msg`App Passwords can only contain letters, numbers, spaces, dashes, and underscores`, + ), + ), + [_], + ) + + const { + mutate: createAppPassword, + error: validationError, + isPending, + } = useMutation< + ComAtprotoServerCreateAppPassword.AppPassword, + Error | DisplayableError + >({ + mutationFn: async () => { + const chosenName = name.trim() || autogeneratedName + if (chosenName.length < 4) { + throw new DisplayableError( + _(msg`App passwords must be at least 4 characters long`), + ) + } + if (passwords.find(p => p === chosenName)) { + throw new DisplayableError(_(msg`Name must be unique`)) + } + return await actuallyCreateAppPassword({name: chosenName, privileged}) + }, + }) + + const [hasBeenCopied, setHasBeenCopied] = useState(false) + useEffect(() => { + if (hasBeenCopied) { + const timeout = setTimeout(() => setHasBeenCopied(false), 100) + return () => clearTimeout(timeout) + } + }, [hasBeenCopied]) + + const error = + validationError || (!name.match(/^[a-zA-Z0-9-_ ]*$/) && regexFailError) + + return ( + + + + {!data ? ( + + + Add App Password + + + + Please enter a unique name for this App Password or use our + randomly generated one. + + + + + createAppPassword()} + blurOnSubmit + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" + autoFocus + /> + + + {error instanceof DisplayableError && ( + + {error.message} + + )} + + + + Allow access to your direct messages + + + + {!!apiError || + (error && !(error instanceof DisplayableError) && ( + + + Failed to create app password. Please try again. + + + ))} + + ) : ( + + + Here is your app password! + + + + Use this to sign into the other app along with your handle. + + + + {hasBeenCopied && ( + + + Copied! + + + )} + + + + + 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. + + + + + )} + + + + + ) +} + +class DisplayableError extends Error { + constructor(message: string) { + super(message) + this.name = 'DisplayableError' + } +} + +function useRandomName() { + return useState( + () => shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)], + )[0] +} + +const shadesOfBlue: string[] = [ + 'AliceBlue', + 'Aqua', + 'Aquamarine', + 'Azure', + 'BabyBlue', + 'Blue', + 'BlueViolet', + 'CadetBlue', + 'CornflowerBlue', + 'Cyan', + 'DarkBlue', + 'DarkCyan', + 'DarkSlateBlue', + 'DeepSkyBlue', + 'DodgerBlue', + 'ElectricBlue', + 'LightBlue', + 'LightCyan', + 'LightSkyBlue', + 'LightSteelBlue', + 'MediumAquaMarine', + 'MediumBlue', + 'MediumSlateBlue', + 'MidnightBlue', + 'Navy', + 'PowderBlue', + 'RoyalBlue', + 'SkyBlue', + 'SlateBlue', + 'SteelBlue', + 'Teal', + 'Turquoise', +] diff --git a/src/screens/Settings/components/SettingsList.tsx b/src/screens/Settings/components/SettingsList.tsx index 86f8040afd2..97e5128dd82 100644 --- a/src/screens/Settings/components/SettingsList.tsx +++ b/src/screens/Settings/components/SettingsList.tsx @@ -17,7 +17,7 @@ const ItemContext = React.createContext({ const Portal = createPortalGroup() export function Container({children}: {children: React.ReactNode}) { - return {children} + return {children} } /** diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx index 98fe6437b69..1b23141f331 100644 --- a/src/view/com/util/error/ErrorScreen.tsx +++ b/src/view/com/util/error/ErrorScreen.tsx @@ -4,15 +4,16 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {Text} from '../text/Text' -import {useTheme} from 'lib/ThemeContext' -import {usePalette} from 'lib/hooks/usePalette' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {usePalette} from '#/lib/hooks/usePalette' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {useTheme} from '#/lib/ThemeContext' +import {ViewHeader} from '#/view/com/util/ViewHeader' import {Button} from '../forms/Button' +import {Text} from '../text/Text' import {CenteredView} from '../Views' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {ViewHeader} from 'view/com/util/ViewHeader' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' export function ErrorScreen({ title, diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index 48abd964f9c..4b2418f48f9 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -15,6 +15,7 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {CommonNavigatorParams} from '#/lib/routes/types' +import {useGate} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {useModalControls} from '#/state/modals' import { @@ -28,14 +29,18 @@ import {Text} from '#/view/com/util/text/Text' import * as Toast from '#/view/com/util/Toast' import {ViewHeader} from '#/view/com/util/ViewHeader' import {CenteredView} from '#/view/com/util/Views' +import {AppPasswordsScreen as NewAppPasswordsScreen} from '#/screens/Settings/AppPasswords' import {atoms as a} from '#/alf' import {useDialogControl} from '#/components/Dialog' import * as Layout from '#/components/Layout' import * as Prompt from '#/components/Prompt' type Props = NativeStackScreenProps -export function AppPasswords({}: Props) { - return ( +export function AppPasswords(props: Props) { + const gate = useGate() + return gate('new_settings') ? ( + + ) : (