diff --git a/src/lib/styles.ts b/src/lib/styles.ts index d307e9ba..a8c38761 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -30,6 +30,8 @@ export const colors = { red3: '#ec4899', red4: '#d1106f', red5: '#97074e', + red6: '#690436', + red7: '#4F0328', pink1: '#f8ccff', pink2: '#e966ff', diff --git a/src/state/models/session.ts b/src/state/models/session.ts index b15c866f..b79283be 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -1,4 +1,4 @@ -import {makeAutoObservable} from 'mobx' +import {makeAutoObservable, runInAction} from 'mobx' import { AtpAgent, AtpSessionEvent, @@ -368,4 +368,45 @@ export class SessionModel { this.clearSessionTokens() this.rootStore.clearAllSessionState() } + + /** + * Removes an account from the list of stored accounts. + */ + removeAccount(handle: string) { + this.accounts = this.accounts.filter(acc => acc.handle !== handle) + } + + /** + * Reloads the session from the server. Useful when account details change, like the handle. + */ + async reloadFromServer() { + const sess = this.currentSession + if (!sess) { + return + } + const res = await this.rootStore.api.app.bsky.actor + .getProfile({actor: sess.did}) + .catch(_e => undefined) + if (res?.success) { + const updated = { + ...sess, + handle: res.data.handle, + displayName: res.data.displayName, + aviUrl: res.data.avatar, + } + runInAction(() => { + this.accounts = [ + updated, + ...this.accounts.filter( + account => + !( + account.service === updated.service && + account.did === updated.did + ), + ), + ] + }) + await this.rootStore.me.load() + } + } } diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts index 0dad2bd9..68d9cd3d 100644 --- a/src/state/models/shell-ui.ts +++ b/src/state/models/shell-ui.ts @@ -51,6 +51,11 @@ export interface RepostModal { isReposted: boolean } +export interface ChangeHandleModal { + name: 'change-handle' + onChanged: () => void +} + export type Modal = | ConfirmModal | EditProfileModal @@ -60,6 +65,7 @@ export type Modal = | CropImageModal | DeleteAccountModal | RepostModal + | ChangeHandleModal interface LightboxModel {} diff --git a/src/view/com/login/Signin.tsx b/src/view/com/login/Signin.tsx index 78b24e68..4f994f83 100644 --- a/src/view/com/login/Signin.tsx +++ b/src/view/com/login/Signin.tsx @@ -296,6 +296,7 @@ const LoginForm = ({ let fullIdent = identifier if ( !identifier.includes('@') && // not an email + !identifier.includes('.') && // not a domain serviceDescription && serviceDescription.availableUserDomains.length > 0 ) { diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx new file mode 100644 index 00000000..519be7b2 --- /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}, +}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 1346f12f..d3a02e0d 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -12,6 +12,7 @@ import * as ReportPostModal from './ReportPost' import * as RepostModal from './Repost' import * as ReportAccountModal from './ReportAccount' import * as DeleteAccountModal from './DeleteAccount' +import * as ChangeHandleModal from './ChangeHandle' import {usePalette} from 'lib/hooks/usePalette' import {StyleSheet} from 'react-native' @@ -65,8 +66,11 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'repost') { snapPoints = RepostModal.snapPoints element = <RepostModal.Component {...activeModal} /> + } else if (activeModal?.name === 'change-handle') { + snapPoints = ChangeHandleModal.snapPoints + element = <ChangeHandleModal.Component {...activeModal} /> } else { - element = <View /> + return <View /> } return ( diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index b10b60be..dd9a3aa6 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -12,6 +12,7 @@ import * as ReportPostModal from './ReportPost' import * as ReportAccountModal from './ReportAccount' import * as RepostModal from './Repost' import * as CropImageModal from './crop-image/CropImage.web' +import * as ChangeHandleModal from './ChangeHandle' export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() @@ -62,6 +63,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <CropImageModal.Component {...modal} /> } else if (modal.name === 'repost') { element = <RepostModal.Component {...modal} /> + } else if (modal.name === 'change-handle') { + element = <ChangeHandleModal.Component {...modal} /> } else { return null } diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 9332b515..47e76a12 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -13,13 +13,15 @@ import {observer} from 'mobx-react-lite' import * as AppInfo from 'lib/app-info' import {useStores} from 'state/index' import {ScreenParams} from '../routes' -import {s} from 'lib/styles' +import {s, colors} from 'lib/styles' import {ScrollView} from '../com/util/Views' import {ViewHeader} from '../com/util/ViewHeader' import {Link} from '../com/util/Link' import {Text} from '../com/util/text/Text' import * as Toast from '../com/util/Toast' import {UserAvatar} from '../com/util/UserAvatar' +import {DropdownButton} from 'view/com/util/forms/DropdownButton' +import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' import {AccountData} from 'state/models/session' import {useAnalytics} from 'lib/analytics' @@ -28,6 +30,7 @@ export const Settings = observer(function Settings({ navIdx, visible, }: ScreenParams) { + const theme = useTheme() const pal = usePalette('default') const store = useStores() const {screen, track} = useAnalytics() @@ -63,6 +66,28 @@ export const Settings = observer(function Settings({ track('Settings:AddAccountButtonClicked') store.session.clear() } + const onPressChangeHandle = () => { + track('Settings:ChangeHandleButtonClicked') + store.shell.openModal({ + name: 'change-handle', + onChanged() { + setIsSwitching(true) + store.session.reloadFromServer().then( + () => { + setIsSwitching(false) + Toast.show('Your handle has been updated') + }, + err => { + store.log.error( + 'Failed to reload from server after handle update', + {err}, + ) + setIsSwitching(false) + }, + ) + }, + }) + } const onPressSignout = () => { track('Settings:SignOutButtonClicked') store.session.logout() @@ -75,145 +100,207 @@ export const Settings = observer(function Settings({ <View style={[s.hContentRegion]} testID="settingsScreen"> <ViewHeader title="Settings" /> <ScrollView style={s.hContentRegion}> - <View style={[s.mt10, s.pl10, s.pr10]}> - <View style={[s.flexRow]}> - <Text type="xl-bold" style={pal.text}> - Signed in as - </Text> - <View style={s.flex1} /> - <TouchableOpacity - testID="signOutBtn" - onPress={isSwitching ? undefined : onPressSignout}> - <Text type="xl-medium" style={pal.link}> - Sign out - </Text> - </TouchableOpacity> + <View style={styles.spacer20} /> + <View style={[s.flexRow, styles.heading]}> + <Text type="xl-bold" style={pal.text}> + Signed in as + </Text> + <View style={s.flex1} /> + </View> + {isSwitching ? ( + <View style={[pal.view, styles.linkCard]}> + <ActivityIndicator /> </View> - {isSwitching ? ( - <View style={[pal.view, styles.profile]}> - <ActivityIndicator /> - </View> - ) : ( - <Link - href={`/profile/${store.me.handle}`} - title="Your profile" - noFeedback> - <View style={[pal.view, styles.profile]}> + ) : ( + <Link + href={`/profile/${store.me.handle}`} + title="Your profile" + noFeedback> + <View style={[pal.view, styles.linkCard]}> + <View style={styles.avi}> <UserAvatar size={40} displayName={store.me.displayName} handle={store.me.handle || ''} avatar={store.me.avatar} /> - <View style={[s.ml10]}> - <Text type="xl-bold" style={pal.text}> - {store.me.displayName || store.me.handle} - </Text> - <Text style={pal.textLight}>@{store.me.handle}</Text> - </View> </View> - </Link> - )} - <Text type="sm-medium" style={pal.text}> - Switch to: - </Text> - {store.session.switchableAccounts.map(account => ( - <TouchableOpacity - testID={`switchToAccountBtn-${account.handle}`} - key={account.did} - style={[ - pal.view, - styles.profile, - s.mb2, - isSwitching && styles.dimmed, - ]} - onPress={ - isSwitching ? undefined : () => onPressSwitchAccount(account) - }> + <View style={[s.flex1]}> + <Text type="md-bold" style={pal.text} numberOfLines={1}> + {store.me.displayName || store.me.handle} + </Text> + <Text type="sm" style={pal.textLight} numberOfLines={1}> + {store.me.handle} + </Text> + </View> + <TouchableOpacity + testID="signOutBtn" + onPress={isSwitching ? undefined : onPressSignout}> + <Text type="lg" style={pal.link}> + Sign out + </Text> + </TouchableOpacity> + </View> + </Link> + )} + {store.session.switchableAccounts.map(account => ( + <TouchableOpacity + testID={`switchToAccountBtn-${account.handle}`} + key={account.did} + style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]} + onPress={ + isSwitching ? undefined : () => onPressSwitchAccount(account) + }> + <View style={styles.avi}> <UserAvatar size={40} displayName={account.displayName} handle={account.handle || ''} avatar={account.aviUrl} /> - <View style={[s.ml10]}> - <Text type="xl-bold" style={pal.text}> - {account.displayName || account.handle} - </Text> - <Text style={pal.textLight}>@{account.handle}</Text> - </View> - </TouchableOpacity> - ))} - <TouchableOpacity - testID="switchToNewAccountBtn" - style={[ - pal.view, - styles.profile, - styles.alignCenter, - s.mb2, - isSwitching && styles.dimmed, - ]} - onPress={isSwitching ? undefined : onPressAddAccount}> + </View> + <View style={[s.flex1]}> + <Text type="md-bold" style={pal.text}> + {account.displayName || account.handle} + </Text> + <Text type="sm" style={pal.textLight}> + {account.handle} + </Text> + </View> + <AccountDropdownBtn handle={account.handle} /> + </TouchableOpacity> + ))} + <TouchableOpacity + testID="switchToNewAccountBtn" + style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} + onPress={isSwitching ? undefined : onPressAddAccount}> + <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="plus" style={pal.text as FontAwesomeIconStyle} /> - <View style={[s.ml5]}> - <Text type="md-medium" style={pal.text}> - Add account - </Text> - </View> - </TouchableOpacity> + </View> + <Text type="lg" style={pal.text}> + Add account + </Text> + </TouchableOpacity> - <View style={styles.spacer} /> - <Text type="sm-medium" style={[s.mb5, pal.text]}> - Danger zone + <View style={styles.spacer20} /> + + <Text type="xl-bold" style={[pal.text, styles.heading]}> + Advanced + </Text> + <TouchableOpacity + testID="changeHandleBtn" + style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} + onPress={isSwitching ? undefined : onPressChangeHandle}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="at" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + Change my handle </Text> - <TouchableOpacity - style={[pal.view, s.p10, s.mb10]} - onPress={onPressDeleteAccount}> - <Text style={pal.textLight}>Delete my account</Text> - </TouchableOpacity> - <Text type="sm-medium" style={[s.mt10, s.mb5, pal.text]}> - Developer tools + </TouchableOpacity> + + <View style={styles.spacer20} /> + + <Text type="xl-bold" style={[pal.text, styles.heading]}> + Danger zone + </Text> + <TouchableOpacity + style={[pal.view, styles.linkCard]} + onPress={onPressDeleteAccount}> + <View + style={[ + styles.iconContainer, + theme.colorScheme === 'dark' + ? styles.trashIconContainerDark + : styles.trashIconContainerLight, + ]}> + <FontAwesomeIcon + icon={['far', 'trash-can']} + style={ + theme.colorScheme === 'dark' + ? styles.dangerDark + : styles.dangerLight + } + size={21} + /> + </View> + <Text + type="lg" + style={ + theme.colorScheme === 'dark' + ? styles.dangerDark + : styles.dangerLight + }> + Delete my account </Text> - <Link - style={[pal.view, s.p10, s.mb2]} - href="/sys/log" - title="System log"> - <Text style={pal.textLight}>System log</Text> - </Link> - <Link - style={[pal.view, s.p10, s.mb2]} - href="/sys/debug" - title="Debug tools"> - <Text style={pal.textLight}>Storybook</Text> - </Link> - <Text type="sm" style={[s.mt10, pal.textLight]}> - Build version {AppInfo.appVersion} ({AppInfo.buildVersion}) + </TouchableOpacity> + + <View style={styles.spacer20} /> + + <Text type="xl-bold" style={[pal.text, styles.heading]}> + Developer tools + </Text> + <Link + style={[pal.view, styles.linkCardNoIcon]} + href="/sys/log" + title="System log"> + <Text type="lg" style={pal.text}> + System log </Text> - <View style={s.footerSpacer} /> - </View> + </Link> + <Link + style={[pal.view, styles.linkCardNoIcon]} + href="/sys/debug" + title="Debug tools"> + <Text type="lg" style={pal.text}> + Storybook + </Text> + </Link> + <Text type="sm" style={[styles.buildInfo, pal.textLight]}> + Build version {AppInfo.appVersion} ({AppInfo.buildVersion}) + </Text> + <View style={s.footerSpacer} /> </ScrollView> </View> ) }) +function AccountDropdownBtn({handle}: {handle: string}) { + const store = useStores() + const items = [ + { + label: 'Remove account', + onPress: () => { + store.session.removeAccount(handle) + Toast.show('Account removed from quick access') + }, + }, + ] + return ( + <View style={s.pl10}> + <DropdownButton type="bare" items={items}> + <FontAwesomeIcon icon="ellipsis-h" /> + </DropdownButton> + </View> + ) +} + const styles = StyleSheet.create({ dimmed: { opacity: 0.5, }, - spacer: { - height: 50, + spacer20: { + height: 20, }, - alignCenter: { - alignItems: 'center', - }, - title: { - fontSize: 32, - fontWeight: 'bold', - marginTop: 20, - marginBottom: 14, + heading: { + paddingHorizontal: 18, + paddingBottom: 6, }, profile: { flexDirection: 'row', @@ -222,10 +309,45 @@ const styles = StyleSheet.create({ paddingVertical: 10, paddingHorizontal: 10, }, + linkCard: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 18, + marginBottom: 1, + }, + linkCardNoIcon: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 20, + paddingHorizontal: 18, + marginBottom: 1, + }, avi: { + marginRight: 12, + }, + iconContainer: { + alignItems: 'center', + justifyContent: 'center', width: 40, height: 40, borderRadius: 30, - marginRight: 8, + marginRight: 12, + }, + trashIconContainerDark: { + backgroundColor: colors.red7, + }, + trashIconContainerLight: { + backgroundColor: colors.red1, + }, + dangerLight: { + color: colors.red4, + }, + dangerDark: { + color: colors.red2, + }, + buildInfo: { + paddingVertical: 8, + paddingHorizontal: 18, }, })