From ba7463cadf15bd5420e1a8cc46952bde2c81cad9 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 12 Feb 2024 13:36:20 -0800 Subject: [PATCH] Improved server selector during account creation and signin (#2840) * Replace the ServerInput modal with a new dialog based on alf that remembers your server address history and doesnt put staging and localdev in the options * Update the server selector during account creation * dont apply capitalization, use url keyboard * Apply insets to dialog top * Improve padding of dialogs on native * Fix race condition in dialog close; also fix fire of the onClose event in dialogs --------- Co-authored-by: Hailey --- src/components/Button.tsx | 1 + src/components/Dialog/index.tsx | 54 +++-- src/components/forms/TextField.tsx | 6 +- src/components/forms/ToggleButton.tsx | 2 +- src/state/modals/index.tsx | 7 - src/state/persisted/legacy.ts | 1 + src/state/persisted/schema.ts | 2 + src/view/com/auth/create/Step1.tsx | 102 +++++++--- .../com/auth/login/ForgotPasswordForm.tsx | 22 +- src/view/com/auth/login/LoginForm.tsx | 17 +- src/view/com/auth/server-input/index.tsx | 173 ++++++++++++++++ src/view/com/modals/Modal.tsx | 4 - src/view/com/modals/Modal.web.tsx | 3 - src/view/com/modals/ServerInput.tsx | 189 ------------------ 14 files changed, 316 insertions(+), 267 deletions(-) create mode 100644 src/view/com/auth/server-input/index.tsx delete mode 100644 src/view/com/modals/ServerInput.tsx diff --git a/src/components/Button.tsx b/src/components/Button.tsx index f88fbcbd..68cee437 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -52,6 +52,7 @@ export type ButtonProps = React.PropsWithChildren< Pick & AccessibilityProps & VariantProps & { + testID?: string label: string style?: StyleProp } diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 44e4dc8a..9132e68d 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -34,6 +34,7 @@ export function Outer({ const sheet = React.useRef(null) const sheetOptions = nativeOptions?.sheet || {} const hasSnapPoints = !!sheetOptions.snapPoints + const insets = useSafeAreaInsets() const open = React.useCallback((i = 0) => { sheet.current?.snapToIndex(i) @@ -41,8 +42,7 @@ export function Outer({ const close = React.useCallback(() => { sheet.current?.close() - onClose?.() - }, [onClose]) + }, []) useImperativeHandle( control.ref, @@ -53,6 +53,15 @@ export function Outer({ [open, close], ) + const onChange = React.useCallback( + (index: number) => { + if (index === -1) { + onClose?.() + } + }, + [onClose], + ) + const context = React.useMemo(() => ({close}), [close]) return ( @@ -63,6 +72,7 @@ export function Outer({ keyboardBehavior="interactive" android_keyboardInputMode="adjustResize" keyboardBlurBehavior="restore" + topInset={insets.top} {...sheetOptions} ref={sheet} index={-1} @@ -77,7 +87,7 @@ export function Outer({ )} handleIndicatorStyle={{backgroundColor: t.palette.primary_500}} handleStyle={{display: 'none'}} - onClose={onClose}> + onChange={onChange}> + + + ) } diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 70f900bb..99d5e715 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -238,10 +238,14 @@ export function createInput(Component: typeof TextInput) { export const Input = createInput(TextInput) -export function Label({children}: React.PropsWithChildren<{}>) { +export function Label({ + nativeID, + children, +}: React.PropsWithChildren<{nativeID?: string}>) { const t = useTheme() return ( {children} diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx index 90790f9f..7e1bd70b 100644 --- a/src/components/forms/ToggleButton.tsx +++ b/src/components/forms/ToggleButton.tsx @@ -8,7 +8,7 @@ import * as Toggle from '#/components/forms/Toggle' export type ItemProps = Omit & AccessibilityProps & - React.PropsWithChildren<{}> + React.PropsWithChildren<{testID?: string}> export type GroupProps = Omit & { multiple?: boolean diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 096211bd..691add00 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -26,12 +26,6 @@ export interface EditProfileModal { onUpdate?: () => void } -export interface ServerInputModal { - name: 'server-input' - initialService: string - onSelect: (url: string) => void -} - export interface ModerationDetailsModal { name: 'moderation-details' context: 'account' | 'content' @@ -222,7 +216,6 @@ export type Modal = | AltTextImageModal | CropImageModal | EditImageModal - | ServerInputModal | RepostModal | SelfLabelModal | ThreadgateModal diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts index cb4b5b1a..cce080c8 100644 --- a/src/state/persisted/legacy.ts +++ b/src/state/persisted/legacy.ts @@ -112,6 +112,7 @@ export function transform(legacy: Partial): Schema { hiddenPosts: defaults.hiddenPosts, externalEmbeds: defaults.externalEmbeds, lastSelectedHomeFeed: defaults.lastSelectedHomeFeed, + pdsAddressHistory: defaults.pdsAddressHistory, } } diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 6771ee6e..0aefaa47 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -57,6 +57,7 @@ export const schema = z.object({ hiddenPosts: z.array(z.string()).optional(), // should move to server useInAppBrowser: z.boolean().optional(), lastSelectedHomeFeed: z.string().optional(), + pdsAddressHistory: z.array(z.string()).optional(), }) export type Schema = z.infer @@ -91,4 +92,5 @@ export const defaults: Schema = { hiddenPosts: [], useInAppBrowser: undefined, lastSelectedHomeFeed: undefined, + pdsAddressHistory: [], } diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index a2663da8..94e03ff7 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -3,6 +3,7 @@ import { ActivityIndicator, Keyboard, StyleSheet, + TouchableOpacity, TouchableWithoutFeedback, View, } from 'react-native' @@ -13,7 +14,6 @@ import {StepHeader} from './StepHeader' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {TextInput} from '../util/TextInput' -import {Button} from '../../util/forms/Button' import {Policies} from './Policies' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {isWeb} from 'platform/detection' @@ -21,7 +21,14 @@ import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useModalControls} from '#/state/modals' import {logger} from '#/logger' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {useDialogControl} from '#/components/Dialog' + +import {ServerInputDialog} from '../server-input' +import {toNiceDomain} from '#/lib/strings/url-helpers' function sanitizeDate(date: Date): Date { if (!date || date.toString() === 'Invalid Date') { @@ -43,16 +50,12 @@ export function Step1({ const pal = usePalette('default') const {_} = useLingui() const {openModal} = useModalControls() + const serverInputControl = useDialogControl() const onPressSelectService = React.useCallback(() => { - openModal({ - name: 'server-input', - initialService: uiState.serviceUrl, - onSelect: (url: string) => - uiDispatch({type: 'set-service-url', value: url}), - }) + serverInputControl.open() Keyboard.dismiss() - }, [uiDispatch, uiState.serviceUrl, openModal]) + }, [serverInputControl]) const onPressWaitlist = React.useCallback(() => { openModal({name: 'waitlist'}) @@ -64,23 +67,72 @@ export function Step1({ return ( - - - + uiDispatch({type: 'set-service-url', value: url})} + /> + + + + + Hosting provider + + + + + + + {toNiceDomain(uiState.serviceUrl)} + + + + + + - + {!uiState.serviceDescription ? ( diff --git a/src/view/com/auth/login/ForgotPasswordForm.tsx b/src/view/com/auth/login/ForgotPasswordForm.tsx index 79399d85..322da2b8 100644 --- a/src/view/com/auth/login/ForgotPasswordForm.tsx +++ b/src/view/com/auth/login/ForgotPasswordForm.tsx @@ -1,6 +1,7 @@ import React, {useState, useEffect} from 'react' import { ActivityIndicator, + Keyboard, TextInput, TouchableOpacity, View, @@ -24,7 +25,9 @@ import {logger} from '#/logger' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {styles} from './styles' -import {useModalControls} from '#/state/modals' +import {useDialogControl} from '#/components/Dialog' + +import {ServerInputDialog} from '../server-input' type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema @@ -51,19 +54,16 @@ export const ForgotPasswordForm = ({ const [email, setEmail] = useState('') const {screen} = useAnalytics() const {_} = useLingui() - const {openModal} = useModalControls() + const serverInputControl = useDialogControl() useEffect(() => { screen('Signin:ForgotPassword') }, [screen]) - const onPressSelectService = () => { - openModal({ - name: 'server-input', - initialService: serviceUrl, - onSelect: setServiceUrl, - }) - } + const onPressSelectService = React.useCallback(() => { + serverInputControl.open() + Keyboard.dismiss() + }, [serverInputControl]) const onPressNext = async () => { if (!EmailValidator.validate(email)) { @@ -96,6 +96,10 @@ export const ForgotPasswordForm = ({ return ( <> + Reset password diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx index 10608a54..e480de7a 100644 --- a/src/view/com/auth/login/LoginForm.tsx +++ b/src/view/com/auth/login/LoginForm.tsx @@ -25,7 +25,9 @@ import {logger} from '#/logger' import {Trans, msg} from '@lingui/macro' import {styles} from './styles' import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' +import {useDialogControl} from '#/components/Dialog' + +import {ServerInputDialog} from '../server-input' type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema @@ -58,15 +60,11 @@ export const LoginForm = ({ const [password, setPassword] = useState('') const passwordInputRef = useRef(null) const {_} = useLingui() - const {openModal} = useModalControls() const {login} = useSessionApi() + const serverInputControl = useDialogControl() const onPressSelectService = () => { - openModal({ - name: 'server-input', - initialService: serviceUrl, - onSelect: setServiceUrl, - }) + serverInputControl.open() Keyboard.dismiss() track('Signin:PressedSelectService') } @@ -130,6 +128,11 @@ export const LoginForm = ({ const isReady = !!serviceDescription && !!identifier && !!password return ( + + Sign into diff --git a/src/view/com/auth/server-input/index.tsx b/src/view/com/auth/server-input/index.tsx new file mode 100644 index 00000000..a7062197 --- /dev/null +++ b/src/view/com/auth/server-input/index.tsx @@ -0,0 +1,173 @@ +import React from 'react' +import {View} from 'react-native' +import {useLingui} from '@lingui/react' +import {Trans, msg} from '@lingui/macro' +import {PROD_SERVICE} from 'lib/constants' +import * as persisted from '#/state/persisted' + +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import * as Dialog from '#/components/Dialog' +import {Text, P} from '#/components/Typography' +import {Button, ButtonText} from '#/components/Button' +import * as ToggleButton from '#/components/forms/ToggleButton' +import * as TextField from '#/components/forms/TextField' +import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' + +export function ServerInputDialog({ + control, + onSelect, +}: { + control: Dialog.DialogOuterProps['control'] + onSelect: (url: string) => void +}) { + const {_} = useLingui() + const t = useTheme() + const {gtMobile} = useBreakpoints() + const [pdsAddressHistory, setPdsAddressHistory] = React.useState( + persisted.get('pdsAddressHistory') || [], + ) + const [fixedOption, setFixedOption] = React.useState([PROD_SERVICE]) + const [customAddress, setCustomAddress] = React.useState('') + + const onClose = React.useCallback(() => { + let url + if (fixedOption[0] === 'custom') { + url = customAddress.trim().toLowerCase() + if (!url) { + return + } + } else { + url = fixedOption[0] + } + if (!url.startsWith('http://') && !url.startsWith('https://')) { + if (url === 'localhost' || url.startsWith('localhost:')) { + url = `http://${url}` + } else { + url = `https://${url}` + } + } + + if (fixedOption[0] === 'custom') { + if (!pdsAddressHistory.includes(url)) { + const newHistory = [url, ...pdsAddressHistory.slice(0, 4)] + setPdsAddressHistory(newHistory) + persisted.write('pdsAddressHistory', newHistory) + } + } + + onSelect(url) + }, [ + fixedOption, + customAddress, + onSelect, + pdsAddressHistory, + setPdsAddressHistory, + ]) + + return ( + + + + + + + Choose Service + +

+ Select the service that hosts your data. +

+ + + + {_(msg`Bluesky`)} + + + {_(msg`Custom`)} + + + + {fixedOption[0] === 'custom' && ( + + + Server address + + + + + + {pdsAddressHistory.length > 0 && ( + + {pdsAddressHistory.map(uri => ( + + ))} + + )} + + )} + + +

+ + Bluesky is an open network where you can choose your hosting + provider. Custom hosting is now available in beta for + developers. + +

+
+ + + + +
+
+
+ ) +} diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index decdc653..8da91c75 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -8,7 +8,6 @@ import {usePalette} from 'lib/hooks/usePalette' import {useModals, useModalControls} from '#/state/modals' import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' -import * as ServerInputModal from './ServerInput' import * as RepostModal from './Repost' import * as SelfLabelModal from './SelfLabel' import * as ThreadgateModal from './Threadgate' @@ -74,9 +73,6 @@ export function ModalsContainer() { } else if (activeModal?.name === 'edit-profile') { snapPoints = EditProfileModal.snapPoints element = - } else if (activeModal?.name === 'server-input') { - snapPoints = ServerInputModal.snapPoints - element = } else if (activeModal?.name === 'report') { snapPoints = ReportModal.snapPoints element = diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index cb6f5bea..97a60be9 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -9,7 +9,6 @@ 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 ServerInputModal from './ServerInput' import * as ReportModal from './report/Modal' import * as AppealLabelModal from './AppealLabel' import * as CreateOrEditListModal from './CreateOrEditList' @@ -84,8 +83,6 @@ function Modal({modal}: {modal: ModalIface}) { element = } else if (modal.name === 'edit-profile') { element = - } else if (modal.name === 'server-input') { - element = } else if (modal.name === 'report') { element = } else if (modal.name === 'appeal-label') { diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx deleted file mode 100644 index 550dffa1..00000000 --- a/src/view/com/modals/ServerInput.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import React, {useState} from 'react' -import {Platform, StyleSheet, TouchableOpacity, View} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {ScrollView, TextInput} from './util' -import {Text} from '../util/text/Text' -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 '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 [customUrl, setCustomUrl] = useState('') - const {_} = useLingui() - const {closeModal} = useModalControls() - - const doSelect = (url: string) => { - if (!url.startsWith('http://') && !url.startsWith('https://')) { - url = `https://${url}` - } - closeModal() - onSelect(url) - } - - return ( - - - Choose Service - - - - {LOGIN_INCLUDE_DEV_SERVERS ? ( - <> - doSelect(LOCAL_DEV_SERVICE)} - accessibilityRole="button"> - - Local dev server - - - - doSelect(STAGING_SERVICE)} - accessibilityRole="button"> - - Staging - - - - - ) : undefined} - doSelect(PROD_SERVICE)} - accessibilityRole="button" - accessibilityLabel={_(msg`Select Bluesky Social`)} - accessibilityHint="Sets Bluesky Social as your service provider"> - - Bluesky.Social - - - - - - - Other service - - - - doSelect(customUrl)} - accessibilityRole="button" - accessibilityLabel={`Confirm service. ${ - customUrl === '' - ? _(msg`Button disabled. Input custom domain to proceed.`) - : '' - }`} - accessibilityHint="" - // TODO - accessibility: Need to inform state change on failure - disabled={customUrl === ''}> - - - - - - - ) -} - -const styles = StyleSheet.create({ - inner: { - padding: 14, - }, - group: { - marginBottom: 20, - }, - label: { - fontWeight: 'bold', - paddingHorizontal: 4, - paddingBottom: 4, - }, - textInput: { - flex: 1, - borderWidth: 1, - borderTopLeftRadius: 6, - borderBottomLeftRadius: 6, - paddingHorizontal: 14, - paddingVertical: 12, - fontSize: 16, - }, - textInputBtn: { - borderWidth: 1, - borderLeftWidth: 0, - borderTopRightRadius: 6, - borderBottomRightRadius: 6, - paddingHorizontal: 14, - paddingVertical: 10, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.blue3, - borderRadius: 6, - paddingHorizontal: 14, - paddingVertical: 10, - marginBottom: 6, - }, - btnText: { - flex: 1, - fontSize: 18, - fontWeight: '500', - color: colors.white, - }, - checkIcon: { - position: 'relative', - ...Platform.select({ - android: { - top: 8, - }, - ios: { - top: 2, - }, - }), - }, -})