about summary refs log tree commit diff
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-05-08 17:25:57 -0500
committerGitHub <noreply@github.com>2023-05-08 17:25:57 -0500
commit7a176b3fdff7d27651b306e7550010b344dfa922 (patch)
tree368d255a28b4b8e445ee66ff7278c792acc40703
parentcdfb1c7abf02ef7896d6cdcf3566ee0c7dd390d3 (diff)
downloadvoidsky-7a176b3fdff7d27651b306e7550010b344dfa922.tar.zst
[APP-615] COPPA-compliant signup (#570)
* Rework account creation to be COPPA compliant

* Fix lint

* Switch android datepicker to use the spinner mode

* Fix type signatures & usages
-rw-r--r--package.json1
-rw-r--r--src/lib/strings/time.ts10
-rw-r--r--src/state/models/ui/create-account.ts19
-rw-r--r--src/view/com/auth/create/Step2.tsx62
-rw-r--r--src/view/com/util/forms/Button.tsx11
-rw-r--r--src/view/com/util/forms/DateInput.tsx96
-rw-r--r--src/view/com/util/forms/DateInput.web.tsx92
-rw-r--r--src/view/index.ts2
-rw-r--r--src/view/screens/Settings.tsx6
-rw-r--r--yarn.lock7
10 files changed, 254 insertions, 52 deletions
diff --git a/package.json b/package.json
index f6ad2ebad..56b0366d4 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
     "@react-native-camera-roll/camera-roll": "^5.2.2",
     "@react-native-clipboard/clipboard": "^1.10.0",
     "@react-native-community/blur": "^4.3.0",
+    "@react-native-community/datetimepicker": "6.7.3",
     "@react-navigation/bottom-tabs": "^6.5.7",
     "@react-navigation/drawer": "^6.6.2",
     "@react-navigation/native": "^6.1.6",
diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts
index 6cd70498e..588b84459 100644
--- a/src/lib/strings/time.ts
+++ b/src/lib/strings/time.ts
@@ -39,3 +39,13 @@ export function niceDate(date: number | string | Date) {
     minute: '2-digit',
   })}`
 }
+
+export function getAge(birthDate: Date): number {
+  var today = new Date()
+  var age = today.getFullYear() - birthDate.getFullYear()
+  var m = today.getMonth() - birthDate.getMonth()
+  if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
+    age--
+  }
+  return age
+}
diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts
index e661cb59d..3f83dd6a7 100644
--- a/src/state/models/ui/create-account.ts
+++ b/src/state/models/ui/create-account.ts
@@ -6,6 +6,9 @@ import {ComAtprotoServerCreateAccount} from '@atproto/api'
 import * as EmailValidator from 'email-validator'
 import {createFullHandle} from 'lib/strings/handles'
 import {cleanError} from 'lib/strings/errors'
+import {getAge} from 'lib/strings/time'
+
+const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
 
 export class CreateAccountModel {
   step: number = 1
@@ -21,7 +24,7 @@ export class CreateAccountModel {
   email = ''
   password = ''
   handle = ''
-  is13 = false
+  birthDate = DEFAULT_DATE
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(this, {}, {autoBind: true})
@@ -32,6 +35,13 @@ export class CreateAccountModel {
 
   next() {
     this.error = ''
+    if (this.step === 2) {
+      if (getAge(this.birthDate) < 13) {
+        this.error =
+          'Unfortunately, you do not meet the requirements to create an account.'
+        return
+      }
+    }
     this.step++
   }
 
@@ -124,8 +134,7 @@ export class CreateAccountModel {
       return (
         (!this.isInviteCodeRequired || this.inviteCode) &&
         !!this.email &&
-        !!this.password &&
-        this.is13
+        !!this.password
       )
     }
     return !!this.handle
@@ -186,7 +195,7 @@ export class CreateAccountModel {
     this.handle = v
   }
 
-  setIs13(v: boolean) {
-    this.is13 = v
+  setBirthDate(v: Date) {
+    this.birthDate = v
   }
 }
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx
index eceee50d3..1e014f18e 100644
--- a/src/view/com/auth/create/Step2.tsx
+++ b/src/view/com/auth/create/Step2.tsx
@@ -1,14 +1,9 @@
 import React from 'react'
-import {
-  StyleSheet,
-  TouchableOpacity,
-  TouchableWithoutFeedback,
-  View,
-} from 'react-native'
+import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {CreateAccountModel} from 'state/models/ui/create-account'
 import {Text} from 'view/com/util/text/Text'
+import {DateInput} from 'view/com/util/forms/DateInput'
 import {StepHeader} from './StepHeader'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -104,26 +99,20 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
             <Text
               type="md-medium"
               style={[pal.text, s.mb2]}
-              nativeID="legalCheck">
-              Legal check
+              nativeID="birthDate">
+              Your birth date
             </Text>
-            <TouchableOpacity
-              testID="is13Input"
-              style={[styles.toggleBtn, pal.border]}
-              onPress={() => model.setIs13(!model.is13)}
-              accessibilityRole="checkbox"
-              accessibilityLabel="Verify age"
-              accessibilityHint="Verifies that I am at least 13 years of age"
-              accessibilityLabelledBy="legalCheck">
-              <View style={[pal.borderDark, styles.checkbox]}>
-                {model.is13 && (
-                  <FontAwesomeIcon icon="check" style={s.blue3} size={16} />
-                )}
-              </View>
-              <Text type="md" style={[pal.text, styles.toggleBtnLabel]}>
-                I am 13 years old or older
-              </Text>
-            </TouchableOpacity>
+            <DateInput
+              testID="birthdayInput"
+              value={model.birthDate}
+              onChange={model.setBirthDate}
+              buttonType="default-light"
+              buttonStyle={[pal.border, styles.dateInputButton]}
+              buttonLabelType="lg"
+              accessibilityLabel="Birthday"
+              accessibilityHint="Enter your birth date"
+              accessibilityLabelledBy="birthDate"
+            />
           </View>
 
           {model.serviceDescription && (
@@ -144,26 +133,9 @@ const styles = StyleSheet.create({
     marginTop: 10,
   },
 
-  toggleBtn: {
-    flexDirection: 'row',
-    flex: 1,
-    alignItems: 'center',
+  dateInputButton: {
     borderWidth: 1,
-    paddingHorizontal: 10,
-    paddingVertical: 10,
     borderRadius: 6,
-  },
-  toggleBtnLabel: {
-    flex: 1,
-    paddingHorizontal: 10,
-  },
-
-  checkbox: {
-    borderWidth: 1,
-    borderRadius: 2,
-    width: 24,
-    height: 24,
-    alignItems: 'center',
-    justifyContent: 'center',
+    paddingVertical: 14,
   },
 })
diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx
index 3b5b00284..1c9b1cf51 100644
--- a/src/view/com/util/forms/Button.tsx
+++ b/src/view/com/util/forms/Button.tsx
@@ -35,6 +35,9 @@ export function Button({
   onPress,
   children,
   testID,
+  accessibilityLabel,
+  accessibilityHint,
+  accessibilityLabelledBy,
 }: React.PropsWithChildren<{
   type?: ButtonType
   label?: string
@@ -42,6 +45,9 @@ export function Button({
   labelStyle?: StyleProp<TextStyle>
   onPress?: () => void
   testID?: string
+  accessibilityLabel?: string
+  accessibilityHint?: string
+  accessibilityLabelledBy?: string
 }>) {
   const theme = useTheme()
   const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
@@ -133,7 +139,10 @@ export function Button({
       style={[typeOuterStyle, styles.outer, style]}
       onPress={onPressWrapped}
       testID={testID}
-      accessibilityRole="button">
+      accessibilityRole="button"
+      accessibilityLabel={accessibilityLabel}
+      accessibilityHint={accessibilityHint}
+      accessibilityLabelledBy={accessibilityLabelledBy}>
       {label ? (
         <Text type="button" style={[typeLabelStyle, labelStyle]}>
           {label}
diff --git a/src/view/com/util/forms/DateInput.tsx b/src/view/com/util/forms/DateInput.tsx
new file mode 100644
index 000000000..4aa5cb610
--- /dev/null
+++ b/src/view/com/util/forms/DateInput.tsx
@@ -0,0 +1,96 @@
+import React, {useState, useCallback} from 'react'
+import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
+import DateTimePicker, {
+  DateTimePickerEvent,
+} from '@react-native-community/datetimepicker'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {isIOS, isAndroid} from 'platform/detection'
+import {Button, ButtonType} from './Button'
+import {Text} from '../text/Text'
+import {TypographyVariant} from 'lib/ThemeContext'
+import {useTheme} from 'lib/ThemeContext'
+import {usePalette} from 'lib/hooks/usePalette'
+
+interface Props {
+  testID?: string
+  value: Date
+  onChange: (date: Date) => void
+  buttonType?: ButtonType
+  buttonStyle?: StyleProp<ViewStyle>
+  buttonLabelType?: TypographyVariant
+  buttonLabelStyle?: StyleProp<TextStyle>
+  accessibilityLabel: string
+  accessibilityHint: string
+  accessibilityLabelledBy?: string
+}
+
+export function DateInput(props: Props) {
+  const [show, setShow] = useState(false)
+  const theme = useTheme()
+  const pal = usePalette('default')
+
+  const onChangeInternal = useCallback(
+    (event: DateTimePickerEvent, date: Date | undefined) => {
+      setShow(false)
+      if (date) {
+        props.onChange(date)
+      }
+    },
+    [setShow, props],
+  )
+
+  const onPress = useCallback(() => {
+    setShow(true)
+  }, [setShow])
+
+  return (
+    <View>
+      {isAndroid && (
+        <Button
+          type={props.buttonType}
+          style={props.buttonStyle}
+          onPress={onPress}
+          accessibilityLabel={props.accessibilityLabel}
+          accessibilityHint={props.accessibilityHint}
+          accessibilityLabelledBy={props.accessibilityLabelledBy}>
+          <View style={styles.button}>
+            <FontAwesomeIcon
+              icon={['far', 'calendar']}
+              style={pal.textLight as FontAwesomeIconStyle}
+            />
+            <Text
+              type={props.buttonLabelType}
+              style={[pal.text, props.buttonLabelStyle]}>
+              {props.value.toLocaleDateString()}
+            </Text>
+          </View>
+        </Button>
+      )}
+      {(isIOS || show) && (
+        <DateTimePicker
+          testID={props.testID ? `${props.testID}-datepicker` : undefined}
+          mode="date"
+          display="spinner"
+          // @ts-ignore applies in iOS only -prf
+          themeVariant={theme.colorScheme}
+          value={props.value}
+          onChange={onChangeInternal}
+          accessibilityLabel={props.accessibilityLabel}
+          accessibilityHint={props.accessibilityHint}
+          accessibilityLabelledBy={props.accessibilityLabelledBy}
+        />
+      )}
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  button: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 10,
+  },
+})
diff --git a/src/view/com/util/forms/DateInput.web.tsx b/src/view/com/util/forms/DateInput.web.tsx
new file mode 100644
index 000000000..89dff5510
--- /dev/null
+++ b/src/view/com/util/forms/DateInput.web.tsx
@@ -0,0 +1,92 @@
+import React, {useState, useCallback} from 'react'
+import {
+  StyleProp,
+  StyleSheet,
+  TextInput as RNTextInput,
+  TextStyle,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useTheme} from 'lib/ThemeContext'
+import {usePalette} from 'lib/hooks/usePalette'
+
+interface Props {
+  testID?: string
+  value: Date
+  onChange: (date: Date) => void
+  buttonType?: string
+  buttonStyle?: StyleProp<ViewStyle>
+  buttonLabelType?: string
+  buttonLabelStyle?: StyleProp<TextStyle>
+  accessibilityLabel: string
+  accessibilityHint: string
+  accessibilityLabelledBy?: string
+}
+
+export function DateInput(props: Props) {
+  const theme = useTheme()
+  const pal = usePalette('default')
+  const palError = usePalette('error')
+  const [value, setValue] = useState(props.value.toLocaleDateString())
+  const [isValid, setIsValid] = useState(true)
+
+  const onChangeInternal = useCallback(
+    (v: string) => {
+      setValue(v)
+      const d = new Date(v)
+      if (!isNaN(Number(d))) {
+        setIsValid(true)
+        props.onChange(d)
+      } else {
+        setIsValid(false)
+      }
+    },
+    [setValue, setIsValid, props],
+  )
+
+  return (
+    <View style={[isValid ? pal.border : palError.border, styles.container]}>
+      <FontAwesomeIcon
+        icon={['far', 'calendar']}
+        style={[pal.textLight, styles.icon]}
+      />
+      <RNTextInput
+        testID={props.testID}
+        style={[pal.text, styles.textInput]}
+        placeholderTextColor={pal.colors.textLight}
+        autoCapitalize="none"
+        autoCorrect={false}
+        keyboardAppearance={theme.colorScheme}
+        onChangeText={v => onChangeInternal(v)}
+        value={value}
+        accessibilityLabel={props.accessibilityLabel}
+        accessibilityHint={props.accessibilityHint}
+        accessibilityLabelledBy={props.accessibilityLabelledBy}
+      />
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    borderWidth: 1,
+    borderRadius: 6,
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 4,
+  },
+  icon: {
+    marginLeft: 10,
+  },
+  textInput: {
+    flex: 1,
+    width: '100%',
+    paddingVertical: 10,
+    paddingHorizontal: 10,
+    fontSize: 17,
+    letterSpacing: 0.25,
+    fontWeight: '400',
+    borderRadius: 10,
+  },
+})
diff --git a/src/view/index.ts b/src/view/index.ts
index 8de035868..dd8a585d6 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -20,6 +20,7 @@ import {faBell} from '@fortawesome/free-solid-svg-icons/faBell'
 import {faBell as farBell} from '@fortawesome/free-regular-svg-icons/faBell'
 import {faBookmark} from '@fortawesome/free-solid-svg-icons/faBookmark'
 import {faBookmark as farBookmark} from '@fortawesome/free-regular-svg-icons/faBookmark'
+import {faCalendar as farCalendar} from '@fortawesome/free-regular-svg-icons/faCalendar'
 import {faCamera} from '@fortawesome/free-solid-svg-icons/faCamera'
 import {faCheck} from '@fortawesome/free-solid-svg-icons/faCheck'
 import {faCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck'
@@ -97,6 +98,7 @@ export function setup() {
     farBell,
     faBookmark,
     farBookmark,
+    farCalendar,
     faCamera,
     faCheck,
     faCircleCheck,
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 5559f036d..f98cdc0c8 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -440,6 +440,7 @@ export const SettingsScreen = withAuthRequired(
 
 function AccountDropdownBtn({handle}: {handle: string}) {
   const store = useStores()
+  const pal = usePalette('default')
   const items = [
     {
       label: 'Remove account',
@@ -452,7 +453,10 @@ function AccountDropdownBtn({handle}: {handle: string}) {
   return (
     <View style={s.pl10}>
       <DropdownButton type="bare" items={items}>
-        <FontAwesomeIcon icon="ellipsis-h" />
+        <FontAwesomeIcon
+          icon="ellipsis-h"
+          style={pal.textLight as FontAwesomeIconStyle}
+        />
       </DropdownButton>
     </View>
   )
diff --git a/yarn.lock b/yarn.lock
index 3351f6b1d..bf556972e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2949,6 +2949,13 @@
     prompts "^2.4.0"
     semver "^6.3.0"
 
+"@react-native-community/datetimepicker@6.7.3":
+  version "6.7.3"
+  resolved "https://registry.yarnpkg.com/@react-native-community/datetimepicker/-/datetimepicker-6.7.3.tgz#e6d75a42729265d8404d1d668c86926564abca2f"
+  integrity sha512-fXWbEdHMLW/e8cts3snEsbOTbnFXfUHeO2pkiDFX3fWpFoDtUrRWvn50xbY13IJUUKHDhoJ+mj24nMRVIXfX1A==
+  dependencies:
+    invariant "^2.2.4"
+
 "@react-native-community/eslint-config@^3.0.0":
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/@react-native-community/eslint-config/-/eslint-config-3.2.0.tgz#42f677d5fff385bccf1be1d3b8faa8c086cf998d"