Refactor ChangeHandle modal (#1929)

* Refactor ChangeHandle to use new methods

* Better telemetry

* Remove unused logic

* Remove caching

* Add error message

* Persist service changes, don't fall back on change handle
zio/stable
Eric Bailey 2023-11-16 11:16:16 -06:00 committed by GitHub
parent e6efeea7c0
commit a652b52b88
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 154 additions and 101 deletions

View File

@ -1,9 +1,10 @@
import React from 'react' import React from 'react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient, useMutation} from '@tanstack/react-query'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
const fetchHandleQueryKey = (handleOrDid: string) => ['handle', handleOrDid] const fetchHandleQueryKey = (handleOrDid: string) => ['handle', handleOrDid]
const fetchDidQueryKey = (handleOrDid: string) => ['did', handleOrDid]
export function useFetchHandle() { export function useFetchHandle() {
const {agent} = useSession() const {agent} = useSession()
@ -23,3 +24,35 @@ export function useFetchHandle() {
[agent, queryClient], [agent, queryClient],
) )
} }
export function useUpdateHandleMutation() {
const {agent} = useSession()
return useMutation({
mutationFn: async ({handle}: {handle: string}) => {
await agent.updateHandle({handle})
},
})
}
export function useFetchDid() {
const {agent} = useSession()
const queryClient = useQueryClient()
return React.useCallback(
async (handleOrDid: string) => {
return queryClient.fetchQuery({
queryKey: fetchDidQueryKey(handleOrDid),
queryFn: async () => {
let identifier = handleOrDid
if (!identifier.startsWith('did:')) {
const res = await agent.resolveHandle({handle: identifier})
identifier = res.data.did
}
return identifier
},
})
},
[agent, queryClient],
)
}

View File

@ -0,0 +1,16 @@
import {useQuery} from '@tanstack/react-query'
import {useSession} from '#/state/session'
export const RQKEY = (serviceUrl: string) => ['service', serviceUrl]
export function useServiceQuery() {
const {agent} = useSession()
return useQuery({
queryKey: RQKEY(agent.service.toString()),
queryFn: async () => {
const res = await agent.com.atproto.server.describeServer()
return res.data
},
})
}

View File

@ -14,8 +14,8 @@ export type SessionState = {
agent: BskyAgent agent: BskyAgent
isInitialLoad: boolean isInitialLoad: boolean
isSwitchingAccounts: boolean isSwitchingAccounts: boolean
accounts: persisted.PersistedAccount[] accounts: SessionAccount[]
currentAccount: persisted.PersistedAccount | undefined currentAccount: SessionAccount | undefined
} }
export type StateContext = SessionState & { export type StateContext = SessionState & {
hasSession: boolean hasSession: boolean
@ -70,15 +70,15 @@ const ApiContext = React.createContext<ApiContext>({
}) })
function createPersistSessionHandler( function createPersistSessionHandler(
account: persisted.PersistedAccount, account: SessionAccount,
persistSessionCallback: (props: { persistSessionCallback: (props: {
expired: boolean expired: boolean
refreshedAccount: persisted.PersistedAccount refreshedAccount: SessionAccount
}) => void, }) => void,
): AtpPersistSessionHandler { ): AtpPersistSessionHandler {
return function persistSession(event, session) { return function persistSession(event, session) {
const expired = !(event === 'create' || event === 'update') const expired = !(event === 'create' || event === 'update')
const refreshedAccount = { const refreshedAccount: SessionAccount = {
service: account.service, service: account.service,
did: session?.did || account.did, did: session?.did || account.did,
handle: session?.handle || account.handle, handle: session?.handle || account.handle,
@ -128,7 +128,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
) )
const upsertAccount = React.useCallback( const upsertAccount = React.useCallback(
(account: persisted.PersistedAccount, expired = false) => { (account: SessionAccount, expired = false) => {
setStateAndPersist(s => { setStateAndPersist(s => {
return { return {
...s, ...s,
@ -164,8 +164,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
throw new Error(`session: createAccount failed to establish a session`) throw new Error(`session: createAccount failed to establish a session`)
} }
const account: persisted.PersistedAccount = { const account: SessionAccount = {
service, service: agent.service.toString(),
did: agent.session.did, did: agent.session.did,
handle: agent.session.handle, handle: agent.session.handle,
email: agent.session.email!, // TODO this is always defined? email: agent.session.email!, // TODO this is always defined?
@ -215,8 +215,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
throw new Error(`session: login failed to establish a session`) throw new Error(`session: login failed to establish a session`)
} }
const account: persisted.PersistedAccount = { const account: SessionAccount = {
service, service: agent.service.toString(),
did: agent.session.did, did: agent.session.did,
handle: agent.session.handle, handle: agent.session.handle,
email: agent.session.email!, // TODO this is always defined? email: agent.session.email!, // TODO this is always defined?
@ -293,9 +293,24 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}), }),
) )
if (!agent.session) {
throw new Error(`session: initSession failed to establish a session`)
}
// ensure changes in handle/email etc are captured on reload
const freshAccount: SessionAccount = {
service: agent.service.toString(),
did: agent.session.did,
handle: agent.session.handle,
email: agent.session.email!, // TODO this is always defined?
emailConfirmed: agent.session.emailConfirmed || false,
refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt,
}
setState(s => ({...s, agent})) setState(s => ({...s, agent}))
upsertAccount(account) upsertAccount(freshAccount)
emitSessionLoaded(account, agent) emitSessionLoaded(freshAccount, agent)
}, },
[upsertAccount], [upsertAccount],
) )

View File

@ -1,5 +1,6 @@
import React, {useState} from 'react' import React, {useState} from 'react'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import {ComAtprotoServerDescribeServer} from '@atproto/api'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import { import {
ActivityIndicator, ActivityIndicator,
@ -13,8 +14,6 @@ import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button' import {Button} from '../util/forms/Button'
import {SelectableBtn} from '../util/forms/SelectableBtn' import {SelectableBtn} from '../util/forms/SelectableBtn'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {useStores} from 'state/index'
import {ServiceDescription} from 'state/models/session'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {createFullHandle, makeValidHandle} from 'lib/strings/handles' import {createFullHandle, makeValidHandle} from 'lib/strings/handles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
@ -25,77 +24,66 @@ import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useServiceQuery} from '#/state/queries/service'
import {useUpdateHandleMutation, useFetchDid} from '#/state/queries/handle'
import {useSession, useSessionApi, SessionAccount} from '#/state/session'
export const snapPoints = ['100%'] export const snapPoints = ['100%']
export function Component({onChanged}: {onChanged: () => void}) { export type Props = {onChanged: () => void}
const store = useStores()
const [error, setError] = useState<string>('') export function Component(props: Props) {
const {currentAccount} = useSession()
const {
isLoading,
data: serviceInfo,
error: serviceInfoError,
} = useServiceQuery()
return isLoading || !currentAccount ? (
<View style={{padding: 18}}>
<ActivityIndicator />
</View>
) : serviceInfoError || !serviceInfo ? (
<ErrorMessage message={cleanError(serviceInfoError)} />
) : (
<Inner
{...props}
currentAccount={currentAccount}
serviceInfo={serviceInfo}
/>
)
}
export function Inner({
currentAccount,
serviceInfo,
onChanged,
}: Props & {
currentAccount: SessionAccount
serviceInfo: ComAtprotoServerDescribeServer.OutputSchema
}) {
const {_} = useLingui()
const pal = usePalette('default') const pal = usePalette('default')
const {track} = useAnalytics() const {track} = useAnalytics()
const {_} = useLingui() const {updateCurrentAccount} = useSessionApi()
const {closeModal} = useModalControls() const {closeModal} = useModalControls()
const {mutateAsync: updateHandle, isPending: isUpdateHandlePending} =
useUpdateHandleMutation()
const [error, setError] = useState<string>('')
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 [isCustom, setCustom] = React.useState<boolean>(false)
const [handle, setHandle] = React.useState<string>('') const [handle, setHandle] = React.useState<string>('')
const [canSave, setCanSave] = React.useState<boolean>(false) const [canSave, setCanSave] = React.useState<boolean>(false)
// init const userDomain = serviceInfo.availableUserDomains?.[0]
// =
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)
logger.warn(
`Failed to fetch service description for ${String(
store.agent.service,
)}`,
{error: err},
)
setError(
'Unable to contact your service. Please check your Internet connection.',
)
},
)
return () => {
aborted = true
}
}, [store.agent.service, store.session, retryDescribeTrigger])
// events // events
// = // =
const onPressCancel = React.useCallback(() => { const onPressCancel = React.useCallback(() => {
closeModal() closeModal()
}, [closeModal]) }, [closeModal])
const onPressRetryConnect = React.useCallback(
() => setRetryDescribeTrigger({}),
[setRetryDescribeTrigger],
)
const onToggleCustom = React.useCallback(() => { const onToggleCustom = React.useCallback(() => {
// toggle between a provided domain vs a custom one // toggle between a provided domain vs a custom one
setHandle('') setHandle('')
@ -106,13 +94,22 @@ export function Component({onChanged}: {onChanged: () => void}) {
) )
}, [setCustom, isCustom, track]) }, [setCustom, isCustom, track])
const onPressSave = React.useCallback(async () => { const onPressSave = React.useCallback(async () => {
setError('') if (!userDomain) {
setProcessing(true) logger.error(`ChangeHandle: userDomain is undefined`, {
service: serviceInfo,
})
setError(`The service you've selected has no domains configured.`)
return
}
try { try {
track('EditHandle:SetNewHandle') track('EditHandle:SetNewHandle')
const newHandle = isCustom ? handle : createFullHandle(handle, userDomain) const newHandle = isCustom ? handle : createFullHandle(handle, userDomain)
logger.debug(`Updating handle to ${newHandle}`) logger.debug(`Updating handle to ${newHandle}`)
await store.agent.updateHandle({ await updateHandle({
handle: newHandle,
})
updateCurrentAccount({
handle: newHandle, handle: newHandle,
}) })
closeModal() closeModal()
@ -121,18 +118,18 @@ export function Component({onChanged}: {onChanged: () => void}) {
setError(cleanError(err)) setError(cleanError(err))
logger.error('Failed to update handle', {handle, error: err}) logger.error('Failed to update handle', {handle, error: err})
} finally { } finally {
setProcessing(false)
} }
}, [ }, [
setError, setError,
setProcessing,
handle, handle,
userDomain, userDomain,
store,
isCustom, isCustom,
onChanged, onChanged,
track, track,
closeModal, closeModal,
updateCurrentAccount,
updateHandle,
serviceInfo,
]) ])
// rendering // rendering
@ -159,19 +156,8 @@ export function Component({onChanged}: {onChanged: () => void}) {
<Trans>Change Handle</Trans> <Trans>Change Handle</Trans>
</Text> </Text>
<View style={styles.titleRight}> <View style={styles.titleRight}>
{isProcessing ? ( {isUpdateHandlePending ? (
<ActivityIndicator /> <ActivityIndicator />
) : error && !serviceDescription ? (
<TouchableOpacity
testID="retryConnectButton"
onPress={onPressRetryConnect}
accessibilityRole="button"
accessibilityLabel={_(msg`Retry change handle`)}
accessibilityHint={`Retries handle change to ${handle}`}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Retry
</Text>
</TouchableOpacity>
) : canSave ? ( ) : canSave ? (
<TouchableOpacity <TouchableOpacity
onPress={onPressSave} onPress={onPressSave}
@ -194,8 +180,9 @@ export function Component({onChanged}: {onChanged: () => void}) {
{isCustom ? ( {isCustom ? (
<CustomHandleForm <CustomHandleForm
currentAccount={currentAccount}
handle={handle} handle={handle}
isProcessing={isProcessing} isProcessing={isUpdateHandlePending}
canSave={canSave} canSave={canSave}
onToggleCustom={onToggleCustom} onToggleCustom={onToggleCustom}
setHandle={setHandle} setHandle={setHandle}
@ -206,7 +193,7 @@ export function Component({onChanged}: {onChanged: () => void}) {
<ProvidedHandleForm <ProvidedHandleForm
handle={handle} handle={handle}
userDomain={userDomain} userDomain={userDomain}
isProcessing={isProcessing} isProcessing={isUpdateHandlePending}
onToggleCustom={onToggleCustom} onToggleCustom={onToggleCustom}
setHandle={setHandle} setHandle={setHandle}
setCanSave={setCanSave} setCanSave={setCanSave}
@ -297,6 +284,7 @@ function ProvidedHandleForm({
* The form for using a custom domain * The form for using a custom domain
*/ */
function CustomHandleForm({ function CustomHandleForm({
currentAccount,
handle, handle,
canSave, canSave,
isProcessing, isProcessing,
@ -305,6 +293,7 @@ function CustomHandleForm({
onPressSave, onPressSave,
setCanSave, setCanSave,
}: { }: {
currentAccount: SessionAccount
handle: string handle: string
canSave: boolean canSave: boolean
isProcessing: boolean isProcessing: boolean
@ -313,7 +302,6 @@ function CustomHandleForm({
onPressSave: () => void onPressSave: () => void
setCanSave: (v: boolean) => void setCanSave: (v: boolean) => void
}) { }) {
const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const palSecondary = usePalette('secondary') const palSecondary = usePalette('secondary')
const palError = usePalette('error') const palError = usePalette('error')
@ -322,12 +310,15 @@ function CustomHandleForm({
const [isVerifying, setIsVerifying] = React.useState(false) const [isVerifying, setIsVerifying] = React.useState(false)
const [error, setError] = React.useState<string>('') const [error, setError] = React.useState<string>('')
const [isDNSForm, setDNSForm] = React.useState<boolean>(true) const [isDNSForm, setDNSForm] = React.useState<boolean>(true)
const fetchDid = useFetchDid()
// events // events
// = // =
const onPressCopy = React.useCallback(() => { const onPressCopy = React.useCallback(() => {
Clipboard.setString(isDNSForm ? `did=${store.me.did}` : store.me.did) Clipboard.setString(
isDNSForm ? `did=${currentAccount.did}` : currentAccount.did,
)
Toast.show('Copied to clipboard') Toast.show('Copied to clipboard')
}, [store.me.did, isDNSForm]) }, [currentAccount, isDNSForm])
const onChangeHandle = React.useCallback( const onChangeHandle = React.useCallback(
(v: string) => { (v: string) => {
setHandle(v) setHandle(v)
@ -342,13 +333,11 @@ function CustomHandleForm({
try { try {
setIsVerifying(true) setIsVerifying(true)
setError('') setError('')
const res = await store.agent.com.atproto.identity.resolveHandle({ const did = await fetchDid(handle)
handle, if (did === currentAccount.did) {
})
if (res.data.did === store.me.did) {
setCanSave(true) setCanSave(true)
} else { } else {
setError(`Incorrect DID returned (got ${res.data.did})`) setError(`Incorrect DID returned (got ${did})`)
} }
} catch (err: any) { } catch (err: any) {
setError(cleanError(err)) setError(cleanError(err))
@ -358,13 +347,13 @@ function CustomHandleForm({
} }
}, [ }, [
handle, handle,
store.me.did, currentAccount,
setIsVerifying, setIsVerifying,
setCanSave, setCanSave,
setError, setError,
canSave, canSave,
onPressSave, onPressSave,
store.agent, fetchDid,
]) ])
// rendering // rendering
@ -442,7 +431,7 @@ function CustomHandleForm({
</Text> </Text>
<View style={[styles.dnsValue]}> <View style={[styles.dnsValue]}>
<Text type="mono" style={[styles.monoText, pal.text]}> <Text type="mono" style={[styles.monoText, pal.text]}>
did={store.me.did} did={currentAccount.did}
</Text> </Text>
</View> </View>
</View> </View>
@ -472,7 +461,7 @@ function CustomHandleForm({
<View style={[styles.valueContainer, pal.btn]}> <View style={[styles.valueContainer, pal.btn]}>
<View style={[styles.dnsValue]}> <View style={[styles.dnsValue]}>
<Text type="mono" style={[styles.monoText, pal.text]}> <Text type="mono" style={[styles.monoText, pal.text]}>
{store.me.did} {currentAccount.did}
</Text> </Text>
</View> </View>
</View> </View>