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
parent
20de7782ba
commit
2f3fc4fe4e
|
@ -30,6 +30,8 @@ export const colors = {
|
||||||
red3: '#ec4899',
|
red3: '#ec4899',
|
||||||
red4: '#d1106f',
|
red4: '#d1106f',
|
||||||
red5: '#97074e',
|
red5: '#97074e',
|
||||||
|
red6: '#690436',
|
||||||
|
red7: '#4F0328',
|
||||||
|
|
||||||
pink1: '#f8ccff',
|
pink1: '#f8ccff',
|
||||||
pink2: '#e966ff',
|
pink2: '#e966ff',
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {makeAutoObservable} from 'mobx'
|
import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
import {
|
import {
|
||||||
AtpAgent,
|
AtpAgent,
|
||||||
AtpSessionEvent,
|
AtpSessionEvent,
|
||||||
|
@ -368,4 +368,45 @@ export class SessionModel {
|
||||||
this.clearSessionTokens()
|
this.clearSessionTokens()
|
||||||
this.rootStore.clearAllSessionState()
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,6 +51,11 @@ export interface RepostModal {
|
||||||
isReposted: boolean
|
isReposted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChangeHandleModal {
|
||||||
|
name: 'change-handle'
|
||||||
|
onChanged: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export type Modal =
|
export type Modal =
|
||||||
| ConfirmModal
|
| ConfirmModal
|
||||||
| EditProfileModal
|
| EditProfileModal
|
||||||
|
@ -60,6 +65,7 @@ export type Modal =
|
||||||
| CropImageModal
|
| CropImageModal
|
||||||
| DeleteAccountModal
|
| DeleteAccountModal
|
||||||
| RepostModal
|
| RepostModal
|
||||||
|
| ChangeHandleModal
|
||||||
|
|
||||||
interface LightboxModel {}
|
interface LightboxModel {}
|
||||||
|
|
||||||
|
|
|
@ -296,6 +296,7 @@ const LoginForm = ({
|
||||||
let fullIdent = identifier
|
let fullIdent = identifier
|
||||||
if (
|
if (
|
||||||
!identifier.includes('@') && // not an email
|
!identifier.includes('@') && // not an email
|
||||||
|
!identifier.includes('.') && // not a domain
|
||||||
serviceDescription &&
|
serviceDescription &&
|
||||||
serviceDescription.availableUserDomains.length > 0
|
serviceDescription.availableUserDomains.length > 0
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -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},
|
||||||
|
})
|
|
@ -12,6 +12,7 @@ import * as ReportPostModal from './ReportPost'
|
||||||
import * as RepostModal from './Repost'
|
import * as RepostModal from './Repost'
|
||||||
import * as ReportAccountModal from './ReportAccount'
|
import * as ReportAccountModal from './ReportAccount'
|
||||||
import * as DeleteAccountModal from './DeleteAccount'
|
import * as DeleteAccountModal from './DeleteAccount'
|
||||||
|
import * as ChangeHandleModal from './ChangeHandle'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {StyleSheet} from 'react-native'
|
import {StyleSheet} from 'react-native'
|
||||||
|
|
||||||
|
@ -65,8 +66,11 @@ export const ModalsContainer = observer(function ModalsContainer() {
|
||||||
} else if (activeModal?.name === 'repost') {
|
} else if (activeModal?.name === 'repost') {
|
||||||
snapPoints = RepostModal.snapPoints
|
snapPoints = RepostModal.snapPoints
|
||||||
element = <RepostModal.Component {...activeModal} />
|
element = <RepostModal.Component {...activeModal} />
|
||||||
|
} else if (activeModal?.name === 'change-handle') {
|
||||||
|
snapPoints = ChangeHandleModal.snapPoints
|
||||||
|
element = <ChangeHandleModal.Component {...activeModal} />
|
||||||
} else {
|
} else {
|
||||||
element = <View />
|
return <View />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -12,6 +12,7 @@ import * as ReportPostModal from './ReportPost'
|
||||||
import * as ReportAccountModal from './ReportAccount'
|
import * as ReportAccountModal from './ReportAccount'
|
||||||
import * as RepostModal from './Repost'
|
import * as RepostModal from './Repost'
|
||||||
import * as CropImageModal from './crop-image/CropImage.web'
|
import * as CropImageModal from './crop-image/CropImage.web'
|
||||||
|
import * as ChangeHandleModal from './ChangeHandle'
|
||||||
|
|
||||||
export const ModalsContainer = observer(function ModalsContainer() {
|
export const ModalsContainer = observer(function ModalsContainer() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
@ -62,6 +63,8 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||||
element = <CropImageModal.Component {...modal} />
|
element = <CropImageModal.Component {...modal} />
|
||||||
} else if (modal.name === 'repost') {
|
} else if (modal.name === 'repost') {
|
||||||
element = <RepostModal.Component {...modal} />
|
element = <RepostModal.Component {...modal} />
|
||||||
|
} else if (modal.name === 'change-handle') {
|
||||||
|
element = <ChangeHandleModal.Component {...modal} />
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,13 +13,15 @@ import {observer} from 'mobx-react-lite'
|
||||||
import * as AppInfo from 'lib/app-info'
|
import * as AppInfo from 'lib/app-info'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {ScreenParams} from '../routes'
|
import {ScreenParams} from '../routes'
|
||||||
import {s} from 'lib/styles'
|
import {s, colors} from 'lib/styles'
|
||||||
import {ScrollView} from '../com/util/Views'
|
import {ScrollView} from '../com/util/Views'
|
||||||
import {ViewHeader} from '../com/util/ViewHeader'
|
import {ViewHeader} from '../com/util/ViewHeader'
|
||||||
import {Link} from '../com/util/Link'
|
import {Link} from '../com/util/Link'
|
||||||
import {Text} from '../com/util/text/Text'
|
import {Text} from '../com/util/text/Text'
|
||||||
import * as Toast from '../com/util/Toast'
|
import * as Toast from '../com/util/Toast'
|
||||||
import {UserAvatar} from '../com/util/UserAvatar'
|
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 {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {AccountData} from 'state/models/session'
|
import {AccountData} from 'state/models/session'
|
||||||
import {useAnalytics} from 'lib/analytics'
|
import {useAnalytics} from 'lib/analytics'
|
||||||
|
@ -28,6 +30,7 @@ export const Settings = observer(function Settings({
|
||||||
navIdx,
|
navIdx,
|
||||||
visible,
|
visible,
|
||||||
}: ScreenParams) {
|
}: ScreenParams) {
|
||||||
|
const theme = useTheme()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const {screen, track} = useAnalytics()
|
const {screen, track} = useAnalytics()
|
||||||
|
@ -63,6 +66,28 @@ export const Settings = observer(function Settings({
|
||||||
track('Settings:AddAccountButtonClicked')
|
track('Settings:AddAccountButtonClicked')
|
||||||
store.session.clear()
|
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 = () => {
|
const onPressSignout = () => {
|
||||||
track('Settings:SignOutButtonClicked')
|
track('Settings:SignOutButtonClicked')
|
||||||
store.session.logout()
|
store.session.logout()
|
||||||
|
@ -75,22 +100,15 @@ export const Settings = observer(function Settings({
|
||||||
<View style={[s.hContentRegion]} testID="settingsScreen">
|
<View style={[s.hContentRegion]} testID="settingsScreen">
|
||||||
<ViewHeader title="Settings" />
|
<ViewHeader title="Settings" />
|
||||||
<ScrollView style={s.hContentRegion}>
|
<ScrollView style={s.hContentRegion}>
|
||||||
<View style={[s.mt10, s.pl10, s.pr10]}>
|
<View style={styles.spacer20} />
|
||||||
<View style={[s.flexRow]}>
|
<View style={[s.flexRow, styles.heading]}>
|
||||||
<Text type="xl-bold" style={pal.text}>
|
<Text type="xl-bold" style={pal.text}>
|
||||||
Signed in as
|
Signed in as
|
||||||
</Text>
|
</Text>
|
||||||
<View style={s.flex1} />
|
<View style={s.flex1} />
|
||||||
<TouchableOpacity
|
|
||||||
testID="signOutBtn"
|
|
||||||
onPress={isSwitching ? undefined : onPressSignout}>
|
|
||||||
<Text type="xl-medium" style={pal.link}>
|
|
||||||
Sign out
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
{isSwitching ? (
|
{isSwitching ? (
|
||||||
<View style={[pal.view, styles.profile]}>
|
<View style={[pal.view, styles.linkCard]}>
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
|
@ -98,122 +116,191 @@ export const Settings = observer(function Settings({
|
||||||
href={`/profile/${store.me.handle}`}
|
href={`/profile/${store.me.handle}`}
|
||||||
title="Your profile"
|
title="Your profile"
|
||||||
noFeedback>
|
noFeedback>
|
||||||
<View style={[pal.view, styles.profile]}>
|
<View style={[pal.view, styles.linkCard]}>
|
||||||
|
<View style={styles.avi}>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
size={40}
|
size={40}
|
||||||
displayName={store.me.displayName}
|
displayName={store.me.displayName}
|
||||||
handle={store.me.handle || ''}
|
handle={store.me.handle || ''}
|
||||||
avatar={store.me.avatar}
|
avatar={store.me.avatar}
|
||||||
/>
|
/>
|
||||||
<View style={[s.ml10]}>
|
</View>
|
||||||
<Text type="xl-bold" style={pal.text}>
|
<View style={[s.flex1]}>
|
||||||
|
<Text type="md-bold" style={pal.text} numberOfLines={1}>
|
||||||
{store.me.displayName || store.me.handle}
|
{store.me.displayName || store.me.handle}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={pal.textLight}>@{store.me.handle}</Text>
|
<Text type="sm" style={pal.textLight} numberOfLines={1}>
|
||||||
|
{store.me.handle}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="signOutBtn"
|
||||||
|
onPress={isSwitching ? undefined : onPressSignout}>
|
||||||
|
<Text type="lg" style={pal.link}>
|
||||||
|
Sign out
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Text type="sm-medium" style={pal.text}>
|
|
||||||
Switch to:
|
|
||||||
</Text>
|
|
||||||
{store.session.switchableAccounts.map(account => (
|
{store.session.switchableAccounts.map(account => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID={`switchToAccountBtn-${account.handle}`}
|
testID={`switchToAccountBtn-${account.handle}`}
|
||||||
key={account.did}
|
key={account.did}
|
||||||
style={[
|
style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]}
|
||||||
pal.view,
|
|
||||||
styles.profile,
|
|
||||||
s.mb2,
|
|
||||||
isSwitching && styles.dimmed,
|
|
||||||
]}
|
|
||||||
onPress={
|
onPress={
|
||||||
isSwitching ? undefined : () => onPressSwitchAccount(account)
|
isSwitching ? undefined : () => onPressSwitchAccount(account)
|
||||||
}>
|
}>
|
||||||
|
<View style={styles.avi}>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
size={40}
|
size={40}
|
||||||
displayName={account.displayName}
|
displayName={account.displayName}
|
||||||
handle={account.handle || ''}
|
handle={account.handle || ''}
|
||||||
avatar={account.aviUrl}
|
avatar={account.aviUrl}
|
||||||
/>
|
/>
|
||||||
<View style={[s.ml10]}>
|
</View>
|
||||||
<Text type="xl-bold" style={pal.text}>
|
<View style={[s.flex1]}>
|
||||||
|
<Text type="md-bold" style={pal.text}>
|
||||||
{account.displayName || account.handle}
|
{account.displayName || account.handle}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={pal.textLight}>@{account.handle}</Text>
|
<Text type="sm" style={pal.textLight}>
|
||||||
|
{account.handle}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<AccountDropdownBtn handle={account.handle} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="switchToNewAccountBtn"
|
testID="switchToNewAccountBtn"
|
||||||
style={[
|
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
|
||||||
pal.view,
|
|
||||||
styles.profile,
|
|
||||||
styles.alignCenter,
|
|
||||||
s.mb2,
|
|
||||||
isSwitching && styles.dimmed,
|
|
||||||
]}
|
|
||||||
onPress={isSwitching ? undefined : onPressAddAccount}>
|
onPress={isSwitching ? undefined : onPressAddAccount}>
|
||||||
|
<View style={[styles.iconContainer, pal.btn]}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="plus"
|
icon="plus"
|
||||||
style={pal.text as FontAwesomeIconStyle}
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
/>
|
/>
|
||||||
<View style={[s.ml5]}>
|
</View>
|
||||||
<Text type="md-medium" style={pal.text}>
|
<Text type="lg" style={pal.text}>
|
||||||
Add account
|
Add account
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.spacer} />
|
<View style={styles.spacer20} />
|
||||||
<Text type="sm-medium" style={[s.mb5, pal.text]}>
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<View style={styles.spacer20} />
|
||||||
|
|
||||||
|
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||||
Danger zone
|
Danger zone
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[pal.view, s.p10, s.mb10]}
|
style={[pal.view, styles.linkCard]}
|
||||||
onPress={onPressDeleteAccount}>
|
onPress={onPressDeleteAccount}>
|
||||||
<Text style={pal.textLight}>Delete my account</Text>
|
<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>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text type="sm-medium" style={[s.mt10, s.mb5, pal.text]}>
|
|
||||||
|
<View style={styles.spacer20} />
|
||||||
|
|
||||||
|
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||||
Developer tools
|
Developer tools
|
||||||
</Text>
|
</Text>
|
||||||
<Link
|
<Link
|
||||||
style={[pal.view, s.p10, s.mb2]}
|
style={[pal.view, styles.linkCardNoIcon]}
|
||||||
href="/sys/log"
|
href="/sys/log"
|
||||||
title="System log">
|
title="System log">
|
||||||
<Text style={pal.textLight}>System log</Text>
|
<Text type="lg" style={pal.text}>
|
||||||
|
System log
|
||||||
|
</Text>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
style={[pal.view, s.p10, s.mb2]}
|
style={[pal.view, styles.linkCardNoIcon]}
|
||||||
href="/sys/debug"
|
href="/sys/debug"
|
||||||
title="Debug tools">
|
title="Debug tools">
|
||||||
<Text style={pal.textLight}>Storybook</Text>
|
<Text type="lg" style={pal.text}>
|
||||||
|
Storybook
|
||||||
|
</Text>
|
||||||
</Link>
|
</Link>
|
||||||
<Text type="sm" style={[s.mt10, pal.textLight]}>
|
<Text type="sm" style={[styles.buildInfo, pal.textLight]}>
|
||||||
Build version {AppInfo.appVersion} ({AppInfo.buildVersion})
|
Build version {AppInfo.appVersion} ({AppInfo.buildVersion})
|
||||||
</Text>
|
</Text>
|
||||||
<View style={s.footerSpacer} />
|
<View style={s.footerSpacer} />
|
||||||
</View>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</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({
|
const styles = StyleSheet.create({
|
||||||
dimmed: {
|
dimmed: {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
spacer: {
|
spacer20: {
|
||||||
height: 50,
|
height: 20,
|
||||||
},
|
},
|
||||||
alignCenter: {
|
heading: {
|
||||||
alignItems: 'center',
|
paddingHorizontal: 18,
|
||||||
},
|
paddingBottom: 6,
|
||||||
title: {
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
marginTop: 20,
|
|
||||||
marginBottom: 14,
|
|
||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
@ -222,10 +309,45 @@ const styles = StyleSheet.create({
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 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: {
|
avi: {
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: 30,
|
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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in New Issue