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 {useQueryClient} from '@tanstack/react-query'
import {useQueryClient, useMutation} from '@tanstack/react-query'
import {useSession} from '#/state/session'
const fetchHandleQueryKey = (handleOrDid: string) => ['handle', handleOrDid]
const fetchDidQueryKey = (handleOrDid: string) => ['did', handleOrDid]
export function useFetchHandle() {
const {agent} = useSession()
@ -23,3 +24,35 @@ export function useFetchHandle() {
[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
isInitialLoad: boolean
isSwitchingAccounts: boolean
accounts: persisted.PersistedAccount[]
currentAccount: persisted.PersistedAccount | undefined
accounts: SessionAccount[]
currentAccount: SessionAccount | undefined
}
export type StateContext = SessionState & {
hasSession: boolean
@ -70,15 +70,15 @@ const ApiContext = React.createContext<ApiContext>({
})
function createPersistSessionHandler(
account: persisted.PersistedAccount,
account: SessionAccount,
persistSessionCallback: (props: {
expired: boolean
refreshedAccount: persisted.PersistedAccount
refreshedAccount: SessionAccount
}) => void,
): AtpPersistSessionHandler {
return function persistSession(event, session) {
const expired = !(event === 'create' || event === 'update')
const refreshedAccount = {
const refreshedAccount: SessionAccount = {
service: account.service,
did: session?.did || account.did,
handle: session?.handle || account.handle,
@ -128,7 +128,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
const upsertAccount = React.useCallback(
(account: persisted.PersistedAccount, expired = false) => {
(account: SessionAccount, expired = false) => {
setStateAndPersist(s => {
return {
...s,
@ -164,8 +164,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
throw new Error(`session: createAccount failed to establish a session`)
}
const account: persisted.PersistedAccount = {
service,
const account: SessionAccount = {
service: agent.service.toString(),
did: agent.session.did,
handle: agent.session.handle,
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`)
}
const account: persisted.PersistedAccount = {
service,
const account: SessionAccount = {
service: agent.service.toString(),
did: agent.session.did,
handle: agent.session.handle,
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}))
upsertAccount(account)
emitSessionLoaded(account, agent)
upsertAccount(freshAccount)
emitSessionLoaded(freshAccount, agent)
},
[upsertAccount],
)

View File

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