Replace resumeSession with getSession in the email check (#8670)

* replace resumeSession with getSession

* copy lil type tweak from the other PR

* Add partialRefreshSession to session API context, use session state to infer state further down tree

* Review

---------

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2025-07-23 19:52:38 +03:00
committed by GitHub
parent b4938bc9df
commit 8fdcc3ee31
7 changed files with 98 additions and 65 deletions
@@ -1,7 +1,7 @@
import {useCallback, useEffect, useState} from 'react' import {useEffect, useMemo, useState} from 'react'
import {useQuery, useQueryClient} from '@tanstack/react-query' import {useQuery} from '@tanstack/react-query'
import {useAgent} from '#/state/session' import {useAgent, useSessionApi} from '#/state/session'
import {emitEmailVerified} from '#/components/dialogs/EmailDialog/events' import {emitEmailVerified} from '#/components/dialogs/EmailDialog/events'
export type AccountEmailState = { export type AccountEmailState = {
@@ -11,57 +11,36 @@ export type AccountEmailState = {
export const accountEmailStateQueryKey = ['accountEmailState'] as const export const accountEmailStateQueryKey = ['accountEmailState'] as const
export function useInvalidateAccountEmailState() {
const qc = useQueryClient()
return useCallback(() => {
return qc.invalidateQueries({
queryKey: accountEmailStateQueryKey,
})
}, [qc])
}
export function useUpdateAccountEmailStateQueryCache() {
const qc = useQueryClient()
return useCallback(
(data: AccountEmailState) => {
return qc.setQueriesData(
{
queryKey: accountEmailStateQueryKey,
},
data,
)
},
[qc],
)
}
export function useAccountEmailState() { export function useAccountEmailState() {
const agent = useAgent() const agent = useAgent()
const {partialRefreshSession} = useSessionApi()
const [prevIsEmailVerified, setPrevEmailIsVerified] = useState( const [prevIsEmailVerified, setPrevEmailIsVerified] = useState(
!!agent.session?.emailConfirmed, !!agent.session?.emailConfirmed,
) )
const fallbackData: AccountEmailState = { const state: AccountEmailState = useMemo(
isEmailVerified: !!agent.session?.emailConfirmed, () => ({
email2FAEnabled: !!agent.session?.emailAuthFactor, isEmailVerified: !!agent.session?.emailConfirmed,
} email2FAEnabled: !!agent.session?.emailAuthFactor,
const query = useQuery<AccountEmailState>({ }),
[agent.session],
)
/**
* Only here to refetch on focus, when necessary
*/
useQuery({
enabled: !!agent.session, enabled: !!agent.session,
refetchOnWindowFocus: true, /**
* Only refetch if the email verification s incomplete.
*/
refetchOnWindowFocus: !prevIsEmailVerified,
queryKey: accountEmailStateQueryKey, queryKey: accountEmailStateQueryKey,
queryFn: async () => { queryFn: async () => {
// will also trigger updates to `#/state/session` data await partialRefreshSession()
const {data} = await agent.resumeSession(agent.session!) return null
return {
isEmailVerified: !!data.emailConfirmed,
email2FAEnabled: !!data.emailAuthFactor,
}
}, },
}) })
const state = query.data ?? fallbackData
/* /*
* This will emit `n` times for each instance of this hook. So the listeners * This will emit `n` times for each instance of this hook. So the listeners
* all use `once` to prevent multiple handlers firing. * all use `once` to prevent multiple handlers firing.
@@ -1,13 +1,10 @@
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {useUpdateAccountEmailStateQueryCache} from '#/components/dialogs/EmailDialog/data/useAccountEmailState'
export function useConfirmEmail() { export function useConfirmEmail() {
const agent = useAgent() const agent = useAgent()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const updateAccountEmailStateQueryCache =
useUpdateAccountEmailStateQueryCache()
return useMutation({ return useMutation({
mutationFn: async ({token}: {token: string}) => { mutationFn: async ({token}: {token: string}) => {
@@ -19,11 +16,8 @@ export function useConfirmEmail() {
email: currentAccount.email, email: currentAccount.email,
token: token.trim(), token: token.trim(),
}) })
const {data} = await agent.resumeSession(agent.session!) // will update session state at root of app
updateAccountEmailStateQueryCache({ await agent.resumeSession(agent.session!)
isEmailVerified: !!data.emailConfirmed,
email2FAEnabled: !!data.emailAuthFactor,
})
}, },
}) })
} }
@@ -1,13 +1,10 @@
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {useUpdateAccountEmailStateQueryCache} from '#/components/dialogs/EmailDialog/data/useAccountEmailState'
export function useManageEmail2FA() { export function useManageEmail2FA() {
const agent = useAgent() const agent = useAgent()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const updateAccountEmailStateQueryCache =
useUpdateAccountEmailStateQueryCache()
return useMutation({ return useMutation({
mutationFn: async ({ mutationFn: async ({
@@ -25,11 +22,8 @@ export function useManageEmail2FA() {
emailAuthFactor: enabled, emailAuthFactor: enabled,
token, token,
}) })
const {data} = await agent.resumeSession(agent.session!) // will update session state at root of app
updateAccountEmailStateQueryCache({ await agent.resumeSession(agent.session!)
isEmailVerified: !!data.emailConfirmed,
email2FAEnabled: !!data.emailAuthFactor,
})
}, },
}) })
} }
+3 -3
View File
@@ -1,9 +1,9 @@
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api' import {type AppBskyFeedDefs, type BskyAgent} from '@atproto/api'
import {PROD_DEFAULT_FEED} from '#/lib/constants' import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {CustomFeedAPI} from './custom' import {CustomFeedAPI} from './custom'
import {FollowingFeedAPI} from './following' import {FollowingFeedAPI} from './following'
import {FeedAPI, FeedAPIResponse} from './types' import {type FeedAPI, type FeedAPIResponse} from './types'
// HACK // HACK
// the feed API does not include any facilities for passing down // the feed API does not include any facilities for passing down
@@ -93,7 +93,7 @@ export class HomeFeedAPI implements FeedAPI {
} }
} }
if (this.usingDiscover) { if (this.usingDiscover && !__DEV__) {
const res = await this.discover.fetch({cursor, limit}) const res = await this.discover.fetch({cursor, limit})
returnCursor = res.cursor returnCursor = res.cursor
posts = posts.concat(res.feed) posts = posts.concat(res.feed)
+21 -1
View File
@@ -40,6 +40,7 @@ const ApiContext = React.createContext<SessionApiContext>({
logoutEveryAccount: async () => {}, logoutEveryAccount: async () => {},
resumeSession: async () => {}, resumeSession: async () => {},
removeAccount: () => {}, removeAccount: () => {},
partialRefreshSession: async () => {},
}) })
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
@@ -119,7 +120,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
) )
const logoutCurrentAccount = React.useCallback< const logoutCurrentAccount = React.useCallback<
SessionApiContext['logoutEveryAccount'] SessionApiContext['logoutCurrentAccount']
>( >(
logContext => { logContext => {
addSessionDebugLog({type: 'method:start', method: 'logout'}) addSessionDebugLog({type: 'method:start', method: 'logout'})
@@ -182,6 +183,23 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[onAgentSessionChange, cancelPendingTask], [onAgentSessionChange, cancelPendingTask],
) )
const partialRefreshSession = React.useCallback<
SessionApiContext['partialRefreshSession']
>(async () => {
const agent = state.currentAgentState.agent as BskyAppAgent
const signal = cancelPendingTask()
const {data} = await agent.com.atproto.server.getSession()
if (signal.aborted) return
dispatch({
type: 'partial-refresh-session',
accountDid: agent.session!.did,
patch: {
emailConfirmed: data.emailConfirmed,
emailAuthFactor: data.emailAuthFactor,
},
})
}, [state, cancelPendingTask])
const removeAccount = React.useCallback<SessionApiContext['removeAccount']>( const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
account => { account => {
addSessionDebugLog({ addSessionDebugLog({
@@ -262,6 +280,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
logoutEveryAccount, logoutEveryAccount,
resumeSession, resumeSession,
removeAccount, removeAccount,
partialRefreshSession,
}), }),
[ [
createAccount, createAccount,
@@ -270,6 +289,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
logoutEveryAccount, logoutEveryAccount,
resumeSession, resumeSession,
removeAccount, removeAccount,
partialRefreshSession,
], ],
) )
+40 -2
View File
@@ -1,8 +1,8 @@
import {AtpSessionEvent} from '@atproto/api' import {type AtpSessionEvent, type BskyAgent} from '@atproto/api'
import {createPublicAgent} from './agent' import {createPublicAgent} from './agent'
import {wrapSessionReducerForLogging} from './logging' import {wrapSessionReducerForLogging} from './logging'
import {SessionAccount} from './types' import {type SessionAccount} from './types'
// A hack so that the reducer can't read anything from the agent. // A hack so that the reducer can't read anything from the agent.
// From the reducer's point of view, it should be a completely opaque object. // From the reducer's point of view, it should be a completely opaque object.
@@ -52,6 +52,11 @@ export type Action =
syncedAccounts: SessionAccount[] syncedAccounts: SessionAccount[]
syncedCurrentDid: string | undefined syncedCurrentDid: string | undefined
} }
| {
type: 'partial-refresh-session'
accountDid: string
patch: Pick<SessionAccount, 'emailConfirmed' | 'emailAuthFactor'>
}
function createPublicAgentState(): AgentState { function createPublicAgentState(): AgentState {
return { return {
@@ -180,6 +185,39 @@ let reducer = (state: State, action: Action): State => {
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles. needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
} }
} }
case 'partial-refresh-session': {
const {accountDid, patch} = action
const agent = state.currentAgentState.agent as BskyAgent
/*
* Only mutating values that are safe. Be very careful with this.
*/
if (agent.session) {
agent.session.emailConfirmed =
patch.emailConfirmed ?? agent.session.emailConfirmed
agent.session.emailAuthFactor =
patch.emailAuthFactor ?? agent.session.emailAuthFactor
}
return {
...state,
currentAgentState: {
...state.currentAgentState,
agent,
},
accounts: state.accounts.map(a => {
if (a.did === accountDid) {
return {
...a,
emailConfirmed: patch.emailConfirmed ?? a.emailConfirmed,
emailAuthFactor: patch.emailAuthFactor ?? a.emailAuthFactor,
}
}
return a
}),
needsPersist: true,
}
}
} }
} }
reducer = wrapSessionReducerForLogging(reducer) reducer = wrapSessionReducerForLogging(reducer)
+8
View File
@@ -40,4 +40,12 @@ export type SessionApiContext = {
) => void ) => void
resumeSession: (account: SessionAccount) => Promise<void> resumeSession: (account: SessionAccount) => Promise<void>
removeAccount: (account: SessionAccount) => void removeAccount: (account: SessionAccount) => void
/**
* Calls `getSession` and updates select fields on the current account and
* `BskyAgent`. This is an alternative to `resumeSession`, which updates
* current account/agent using the `persistSessionHandler`, but is more load
* bearing. This patches in updates without causing any side effects via
* `persistSessionHandler`.
*/
partialRefreshSession: () => Promise<void>
} }