about summary refs log tree commit diff
path: root/src/view/com/modals/ChangeHandle.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/modals/ChangeHandle.tsx')
-rw-r--r--src/view/com/modals/ChangeHandle.tsx518
1 files changed, 518 insertions, 0 deletions
diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx
new file mode 100644
index 000000000..519be7b2e
--- /dev/null
+++ b/src/view/com/modals/ChangeHandle.tsx
@@ -0,0 +1,518 @@
+import React, {useState} from 'react'
+import Clipboard from '@react-native-clipboard/clipboard'
+import * as Toast from '../util/Toast'
+import {
+  ActivityIndicator,
+  StyleSheet,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {ScrollView, TextInput} from './util'
+import {Text} from '../util/text/Text'
+import {Button} from '../util/forms/Button'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {useStores} from 'state/index'
+import {ServiceDescription} from 'state/models/session'
+import {s} from 'lib/styles'
+import {makeValidHandle, createFullHandle} from 'lib/strings/handles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnalytics} from 'lib/analytics'
+import {cleanError} from 'lib/strings/errors'
+
+export const snapPoints = ['100%']
+
+export function Component({onChanged}: {onChanged: () => void}) {
+  const store = useStores()
+  const [error, setError] = useState<string>('')
+  const pal = usePalette('default')
+  const {track} = useAnalytics()
+
+  const [isProcessing, setProcessing] = useState<boolean>(false)
+  const [retryDescribeTrigger, setRetryDescribeTrigger] = React.useState<any>(
+    {},
+  )
+  const [serviceDescription, setServiceDescription] = React.useState<
+    ServiceDescription | undefined
+  >(undefined)
+  const [userDomain, setUserDomain] = React.useState<string>('')
+  const [isCustom, setCustom] = React.useState<boolean>(false)
+  const [handle, setHandle] = React.useState<string>('')
+  const [canSave, setCanSave] = React.useState<boolean>(false)
+
+  // init
+  // =
+  React.useEffect(() => {
+    let aborted = false
+    setError('')
+    setServiceDescription(undefined)
+    setProcessing(true)
+
+    // load the service description so we can properly provision handles
+    store.session.describeService(String(store.agent.service)).then(
+      desc => {
+        if (aborted) {
+          return
+        }
+        setServiceDescription(desc)
+        setUserDomain(desc.availableUserDomains[0])
+        setProcessing(false)
+      },
+      err => {
+        if (aborted) {
+          return
+        }
+        setProcessing(false)
+        store.log.warn(
+          `Failed to fetch service description for ${String(
+            store.agent.service,
+          )}`,
+          err,
+        )
+        setError(
+          'Unable to contact your service. Please check your Internet connection.',
+        )
+      },
+    )
+    return () => {
+      aborted = true
+    }
+  }, [store.agent.service, store.session, store.log, retryDescribeTrigger])
+
+  // events
+  // =
+  const onPressCancel = React.useCallback(() => {
+    store.shell.closeModal()
+  }, [store])
+  const onPressRetryConnect = React.useCallback(
+    () => setRetryDescribeTrigger({}),
+    [setRetryDescribeTrigger],
+  )
+  const onToggleCustom = React.useCallback(() => {
+    // toggle between a provided domain vs a custom one
+    setHandle('')
+    setCanSave(false)
+    setCustom(!isCustom)
+    track(
+      isCustom ? 'EditHandle:ViewCustomForm' : 'EditHandle:ViewProvidedForm',
+    )
+  }, [setCustom, isCustom, track])
+  const onPressSave = React.useCallback(async () => {
+    setError('')
+    setProcessing(true)
+    try {
+      track('EditHandle:SetNewHandle')
+      const newHandle = isCustom ? handle : createFullHandle(handle, userDomain)
+      store.log.debug(`Updating handle to ${newHandle}`)
+      await store.api.com.atproto.handle.update({
+        handle: newHandle,
+      })
+      store.shell.closeModal()
+      onChanged()
+    } catch (err: any) {
+      setError(cleanError(err))
+      store.log.error('Failed to update handle', {handle, err})
+    } finally {
+      setProcessing(false)
+    }
+  }, [
+    setError,
+    setProcessing,
+    handle,
+    userDomain,
+    store,
+    isCustom,
+    onChanged,
+    track,
+  ])
+
+  // rendering
+  // =
+  return (
+    <View style={[s.flex1, pal.view]}>
+      <View style={[styles.title, pal.border]}>
+        <View style={styles.titleLeft}>
+          <TouchableOpacity onPress={onPressCancel}>
+            <Text type="lg" style={pal.textLight}>
+              Cancel
+            </Text>
+          </TouchableOpacity>
+        </View>
+        <Text type="2xl-bold" style={[styles.titleMiddle, pal.text]}>
+          Change my handle
+        </Text>
+        <View style={styles.titleRight}>
+          {isProcessing ? (
+            <ActivityIndicator />
+          ) : error && !serviceDescription ? (
+            <TouchableOpacity
+              testID="retryConnectButton"
+              onPress={onPressRetryConnect}>
+              <Text type="xl-bold" style={[pal.link, s.pr5]}>
+                Retry
+              </Text>
+            </TouchableOpacity>
+          ) : canSave ? (
+            <TouchableOpacity onPress={onPressSave}>
+              <Text type="2xl-medium" style={pal.link}>
+                Save
+              </Text>
+            </TouchableOpacity>
+          ) : undefined}
+        </View>
+      </View>
+      <ScrollView style={styles.inner}>
+        {error !== '' && (
+          <View style={styles.errorContainer}>
+            <ErrorMessage message={error} />
+          </View>
+        )}
+
+        {isCustom ? (
+          <CustomHandleForm
+            handle={handle}
+            isProcessing={isProcessing}
+            canSave={canSave}
+            onToggleCustom={onToggleCustom}
+            setHandle={setHandle}
+            setCanSave={setCanSave}
+            onPressSave={onPressSave}
+          />
+        ) : (
+          <ProvidedHandleForm
+            handle={handle}
+            userDomain={userDomain}
+            isProcessing={isProcessing}
+            onToggleCustom={onToggleCustom}
+            setHandle={setHandle}
+            setCanSave={setCanSave}
+          />
+        )}
+      </ScrollView>
+    </View>
+  )
+}
+
+/**
+ * The form for using a domain allocated by the PDS
+ */
+function ProvidedHandleForm({
+  userDomain,
+  handle,
+  isProcessing,
+  setHandle,
+  onToggleCustom,
+  setCanSave,
+}: {
+  userDomain: string
+  handle: string
+  isProcessing: boolean
+  setHandle: (v: string) => void
+  onToggleCustom: () => void
+  setCanSave: (v: boolean) => void
+}) {
+  const pal = usePalette('default')
+
+  // events
+  // =
+  const onChangeHandle = React.useCallback(
+    (v: string) => {
+      const newHandle = makeValidHandle(v)
+      setHandle(newHandle)
+      setCanSave(newHandle.length > 0)
+    },
+    [setHandle, setCanSave],
+  )
+
+  // rendering
+  // =
+  return (
+    <>
+      <View style={[pal.btn, styles.textInputWrapper]}>
+        <FontAwesomeIcon
+          icon="at"
+          style={[pal.textLight, styles.textInputIcon]}
+        />
+        <TextInput
+          testID="setHandleInput"
+          style={[pal.text, styles.textInput]}
+          placeholder="eg alice"
+          placeholderTextColor={pal.colors.textLight}
+          autoCapitalize="none"
+          value={handle}
+          onChangeText={onChangeHandle}
+          editable={!isProcessing}
+        />
+      </View>
+      <Text type="md" style={[pal.textLight, s.pl10, s.pt10]}>
+        Your full handle will be{' '}
+        <Text type="md-bold" style={pal.textLight}>
+          @{createFullHandle(handle, userDomain)}
+        </Text>
+      </Text>
+      <TouchableOpacity onPress={onToggleCustom}>
+        <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}>
+          I have my own domain
+        </Text>
+      </TouchableOpacity>
+    </>
+  )
+}
+
+/**
+ * The form for using a custom domain
+ */
+function CustomHandleForm({
+  handle,
+  canSave,
+  isProcessing,
+  setHandle,
+  onToggleCustom,
+  onPressSave,
+  setCanSave,
+}: {
+  handle: string
+  canSave: boolean
+  isProcessing: boolean
+  setHandle: (v: string) => void
+  onToggleCustom: () => void
+  onPressSave: () => void
+  setCanSave: (v: boolean) => void
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+  const palSecondary = usePalette('secondary')
+  const palError = usePalette('error')
+  const [isVerifying, setIsVerifying] = React.useState(false)
+  const [error, setError] = React.useState<string>('')
+
+  // events
+  // =
+  const onPressCopy = React.useCallback(() => {
+    Clipboard.setString(`did=${store.me.did}`)
+    Toast.show('Copied to clipboard')
+  }, [store.me.did])
+  const onChangeHandle = React.useCallback(
+    (v: string) => {
+      setHandle(v)
+      setCanSave(false)
+    },
+    [setHandle, setCanSave],
+  )
+  const onPressVerify = React.useCallback(async () => {
+    if (canSave) {
+      onPressSave()
+    }
+    try {
+      setIsVerifying(true)
+      setError('')
+      const res = await store.api.com.atproto.handle.resolve({handle})
+      if (res.data.did === store.me.did) {
+        setCanSave(true)
+      } else {
+        setError(`Incorrect DID returned (got ${res.data.did})`)
+      }
+    } catch (err: any) {
+      setError(cleanError(err))
+      store.log.error('Failed to verify domain', {handle, err})
+    } finally {
+      setIsVerifying(false)
+    }
+  }, [
+    handle,
+    store.me.did,
+    setIsVerifying,
+    setCanSave,
+    setError,
+    canSave,
+    onPressSave,
+    store.log,
+    store.api,
+  ])
+
+  // rendering
+  // =
+  return (
+    <>
+      <Text type="md" style={[pal.text, s.pb5, s.pl5]}>
+        Enter the domain you want to use
+      </Text>
+      <View style={[pal.btn, styles.textInputWrapper]}>
+        <FontAwesomeIcon
+          icon="at"
+          style={[pal.textLight, styles.textInputIcon]}
+        />
+        <TextInput
+          testID="setHandleInput"
+          style={[pal.text, styles.textInput]}
+          placeholder="eg alice.com"
+          placeholderTextColor={pal.colors.textLight}
+          autoCapitalize="none"
+          value={handle}
+          onChangeText={onChangeHandle}
+          editable={!isProcessing}
+        />
+      </View>
+      <View style={styles.spacer} />
+      <Text type="md" style={[pal.text, s.pb5, s.pl5]}>
+        Add the following record to your domain:
+      </Text>
+      <View style={[styles.dnsTable, pal.btn]}>
+        <Text type="md-medium" style={styles.dnsLabel}>
+          Domain:
+        </Text>
+        <View style={[styles.dnsValue]}>
+          <Text type="mono" style={[styles.monoText, pal.text]}>
+            _atproto.{handle}
+          </Text>
+        </View>
+        <Text type="md-medium" style={styles.dnsLabel}>
+          Type:
+        </Text>
+        <View style={[styles.dnsValue]}>
+          <Text type="mono" style={[styles.monoText, pal.text]}>
+            TXT
+          </Text>
+        </View>
+        <Text type="md-medium" style={styles.dnsLabel}>
+          Value:
+        </Text>
+        <View style={[styles.dnsValue]}>
+          <Text type="mono" style={[styles.monoText, pal.text]}>
+            did={store.me.did}
+          </Text>
+        </View>
+      </View>
+      <View style={styles.spacer} />
+      <Button type="default" style={[s.p20, s.mb10]} onPress={onPressCopy}>
+        <Text type="xl" style={[pal.link, s.textCenter]}>
+          Copy Domain Value
+        </Text>
+      </Button>
+      {canSave === true && (
+        <View style={[styles.message, palSecondary.view]}>
+          <Text type="md-medium" style={palSecondary.text}>
+            Domain verified!
+          </Text>
+        </View>
+      )}
+      {error && (
+        <View style={[styles.message, palError.view]}>
+          <Text type="md-medium" style={palError.text}>
+            {error}
+          </Text>
+        </View>
+      )}
+      <Button
+        type="primary"
+        style={[s.p20, isVerifying && styles.dimmed]}
+        onPress={onPressVerify}>
+        {isVerifying ? (
+          <ActivityIndicator color="white" />
+        ) : (
+          <Text type="xl-medium" style={[pal.textInverted, s.textCenter]}>
+            {canSave ? `Update to ${handle}` : 'Verify DNS Record'}
+          </Text>
+        )}
+      </Button>
+      <View style={styles.spacer} />
+      <TouchableOpacity onPress={onToggleCustom}>
+        <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}>
+          Nevermind, create a handle for me
+        </Text>
+      </TouchableOpacity>
+    </>
+  )
+}
+
+const styles = StyleSheet.create({
+  inner: {
+    padding: 14,
+  },
+  footer: {
+    padding: 14,
+  },
+  spacer: {
+    height: 20,
+  },
+  dimmed: {
+    opacity: 0.7,
+  },
+
+  title: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingTop: 25,
+    paddingHorizontal: 20,
+    paddingBottom: 15,
+    borderBottomWidth: 1,
+  },
+  titleLeft: {
+    width: 80,
+  },
+  titleRight: {
+    width: 80,
+    flexDirection: 'row',
+    justifyContent: 'flex-end',
+  },
+  titleMiddle: {
+    flex: 1,
+    textAlign: 'center',
+    fontSize: 21,
+  },
+
+  textInputWrapper: {
+    borderRadius: 8,
+    flexDirection: 'row',
+    alignItems: 'center',
+  },
+  textInputIcon: {
+    marginLeft: 12,
+  },
+  textInput: {
+    flex: 1,
+    width: '100%',
+    paddingVertical: 10,
+    paddingHorizontal: 8,
+    fontSize: 17,
+    letterSpacing: 0.25,
+    fontWeight: '400',
+    borderRadius: 10,
+  },
+
+  dnsTable: {
+    borderRadius: 4,
+    paddingTop: 2,
+    paddingBottom: 16,
+  },
+  dnsLabel: {
+    paddingHorizontal: 14,
+    paddingTop: 10,
+  },
+  dnsValue: {
+    paddingHorizontal: 14,
+    borderRadius: 4,
+  },
+  monoText: {
+    fontSize: 18,
+    lineHeight: 20,
+  },
+
+  message: {
+    paddingHorizontal: 12,
+    paddingVertical: 10,
+    borderRadius: 8,
+    marginBottom: 10,
+  },
+
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    width: '100%',
+    borderRadius: 32,
+    padding: 10,
+    marginBottom: 10,
+  },
+  errorContainer: {marginBottom: 10},
+})