Handle-change modal with custom domain support (#273)

* Dont append the server's domain name when a custom domain is used

* Update the settings look & feel and add a tool to remove accounts from the switcher

* Try not rendering the bottomsheet when no modal is active.

There are cases where the bottomsheet decides to show itself when
it's not supposed to. It seems obvious to do what this change is
doing -- just dont render bottomsheet if no modal is active -- but
previously we experienced issues with that approach. This time it
seems to be working, so we're gonna yolo try it.

* Implement a handle-change modal with support for custom domains (closes #65)
zio/stable
Paul Frazee 2023-03-06 21:37:48 -06:00 committed by GitHub
parent 20de7782ba
commit 2f3fc4fe4e
8 changed files with 808 additions and 111 deletions

View File

@ -30,6 +30,8 @@ export const colors = {
red3: '#ec4899',
red4: '#d1106f',
red5: '#97074e',
red6: '#690436',
red7: '#4F0328',
pink1: '#f8ccff',
pink2: '#e966ff',

View File

@ -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()
}
}
}

View File

@ -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 {}

View File

@ -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
) {

View File

@ -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},
})

View File

@ -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 (

View File

@ -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
}

View File

@ -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,
},
})