From ab878ba9a6afaa57805aeab988b01c5b47bc9286 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 9 Nov 2023 20:35:17 -0600 Subject: [PATCH 1/8] Web login/signup and shell --- src/data/useGetProfile.ts | 33 +++ src/lib/hooks/useAccountSwitcher.ts | 57 ++--- src/state/models/ui/create-account.ts | 11 +- src/state/persisted/schema.ts | 2 + src/state/session/index.tsx | 178 +++++++++---- src/view/com/auth/LoggedOut.tsx | 7 +- src/view/com/auth/create/CreateAccount.tsx | 10 +- src/view/com/auth/login/ChooseAccountForm.tsx | 113 +++++---- .../com/auth/login/ForgotPasswordForm.tsx | 2 - src/view/com/auth/login/Login.tsx | 8 +- src/view/com/auth/login/LoginForm.tsx | 8 - .../com/auth/login/SetNewPasswordForm.tsx | 2 - src/view/com/auth/withAuthRequired.tsx | 8 +- src/view/com/modals/ChangeEmail.tsx | 25 +- src/view/com/modals/SwitchAccount.tsx | 149 ++++++----- src/view/com/modals/VerifyEmail.tsx | 20 +- src/view/screens/Settings.tsx | 236 +++++++++++------- src/view/shell/desktop/LeftNav.tsx | 63 +++-- src/view/shell/desktop/RightNav.tsx | 10 +- src/view/shell/index.tsx | 7 +- src/view/shell/index.web.tsx | 6 +- 21 files changed, 581 insertions(+), 374 deletions(-) create mode 100644 src/data/useGetProfile.ts diff --git a/src/data/useGetProfile.ts b/src/data/useGetProfile.ts new file mode 100644 index 00000000..58f24a4e --- /dev/null +++ b/src/data/useGetProfile.ts @@ -0,0 +1,33 @@ +import React from 'react' +import {useQuery} from '@tanstack/react-query' +import {BskyAgent} from '@atproto/api' + +import {useSession} from '#/state/session' + +export function useGetProfile({did}: {did: string}) { + const {accounts} = useSession() + const account = React.useMemo( + () => accounts.find(a => a.did === did), + [did, accounts], + ) + + return useQuery({ + enabled: !!account, + queryKey: ['getProfile', account], + queryFn: async () => { + if (!account) { + throw new Error(`useGetProfile: local account not found for ${did}`) + } + + const agent = new BskyAgent({ + // needs to be public data, so remap PDS URLs to App View for now + service: account.service.includes('bsky.social') + ? 'https://api.bsky.app' + : account.service, + }) + + const res = await agent.getProfile({actor: did}) + return res.data + }, + }) +} diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts index b165fddb..83853673 100644 --- a/src/lib/hooks/useAccountSwitcher.ts +++ b/src/lib/hooks/useAccountSwitcher.ts @@ -1,46 +1,43 @@ -import {useCallback, useState} from 'react' -import {useStores} from 'state/index' -import {useAnalytics} from 'lib/analytics/analytics' -import {StackActions, useNavigation} from '@react-navigation/native' -import {NavigationProp} from 'lib/routes/types' -import {AccountData} from 'state/models/session' -import {reset as resetNavigation} from '../../Navigation' -import * as Toast from 'view/com/util/Toast' +import {useCallback} from 'react' + +import {useAnalytics} from '#/lib/analytics/analytics' +import {useStores} from '#/state/index' import {useSetDrawerOpen} from '#/state/shell/drawer-open' import {useModalControls} from '#/state/modals' +import {useSessionApi, SessionAccount} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' -export function useAccountSwitcher(): [ - boolean, - (v: boolean) => void, - (acct: AccountData) => Promise, -] { +export function useAccountSwitcher() { const {track} = useAnalytics() const store = useStores() const setDrawerOpen = useSetDrawerOpen() const {closeModal} = useModalControls() - const [isSwitching, setIsSwitching] = useState(false) - const navigation = useNavigation() + const {selectAccount, clearCurrentAccount} = useSessionApi() const onPressSwitchAccount = useCallback( - async (acct: AccountData) => { + async (acct: SessionAccount) => { track('Settings:SwitchAccountButtonClicked') - setIsSwitching(true) - const success = await store.session.resumeSession(acct) - setDrawerOpen(false) - closeModal() - store.shell.closeAllActiveElements() - if (success) { - resetNavigation() - Toast.show(`Signed in as ${acct.displayName || acct.handle}`) - } else { + + try { + await selectAccount(acct) + setDrawerOpen(false) + closeModal() + store.shell.closeAllActiveElements() + Toast.show(`Signed in as ${acct.handle}`) + } catch (e) { Toast.show('Sorry! We need you to enter your password.') - navigation.navigate('HomeTab') - navigation.dispatch(StackActions.popToTop()) - store.session.clear() + clearCurrentAccount() // back user out to login } }, - [track, setIsSwitching, navigation, store, setDrawerOpen, closeModal], + [ + track, + store, + setDrawerOpen, + closeModal, + clearCurrentAccount, + selectAccount, + ], ) - return [isSwitching, setIsSwitching, onPressSwitchAccount] + return {onPressSwitchAccount} } diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts index 39c881db..6d76784c 100644 --- a/src/state/models/ui/create-account.ts +++ b/src/state/models/ui/create-account.ts @@ -10,6 +10,7 @@ import {getAge} from 'lib/strings/time' import {track} from 'lib/analytics/analytics' import {logger} from '#/logger' import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding' +import {ApiContext as SessionApiContext} from '#/state/session' const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago @@ -91,7 +92,13 @@ export class CreateAccountModel { } } - async submit(onboardingDispatch: OnboardingDispatchContext) { + async submit({ + createAccount, + onboardingDispatch, + }: { + createAccount: SessionApiContext['createAccount'] + onboardingDispatch: OnboardingDispatchContext + }) { if (!this.email) { this.setStep(2) return this.setError('Please enter your email.') @@ -113,7 +120,7 @@ export class CreateAccountModel { try { onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view - await this.rootStore.session.createAccount({ + await createAccount({ service: this.serviceUrl, email: this.email, handle: createFullHandle(this.handle, this.userDomain), diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index a510262f..93547aa5 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -7,6 +7,8 @@ const accountSchema = z.object({ service: z.string(), did: z.string(), handle: z.string(), + email: z.string(), + emailConfirmed: z.boolean(), refreshJwt: z.string().optional(), // optional because it can expire accessJwt: z.string().optional(), // optional because it can expire // displayName: z.string().optional(), diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 8e1f9c1a..668d9d8c 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -8,8 +8,9 @@ import * as persisted from '#/state/persisted' export type SessionAccount = persisted.PersistedAccount export type StateContext = { - isInitialLoad: boolean agent: BskyAgent + isInitialLoad: boolean + isSwitchingAccounts: boolean accounts: persisted.PersistedAccount[] currentAccount: persisted.PersistedAccount | undefined hasSession: boolean @@ -33,9 +34,13 @@ export type ApiContext = { removeAccount: ( account: Partial>, ) => void + selectAccount: (account: persisted.PersistedAccount) => Promise updateCurrentAccount: ( - account: Pick, + account: Partial< + Pick + >, ) => void + clearCurrentAccount: () => void } export const PUBLIC_BSKY_AGENT = new BskyAgent({ @@ -43,11 +48,12 @@ export const PUBLIC_BSKY_AGENT = new BskyAgent({ }) const StateContext = React.createContext({ + agent: PUBLIC_BSKY_AGENT, hasSession: false, isInitialLoad: true, + isSwitchingAccounts: false, accounts: [], currentAccount: undefined, - agent: PUBLIC_BSKY_AGENT, }) const ApiContext = React.createContext({ @@ -57,7 +63,9 @@ const ApiContext = React.createContext({ initSession: async () => {}, resumeSession: async () => {}, removeAccount: () => {}, + selectAccount: async () => {}, updateCurrentAccount: () => {}, + clearCurrentAccount: () => {}, }) function createPersistSessionHandler( @@ -73,15 +81,21 @@ function createPersistSessionHandler( service: account.service, did: session?.did || account.did, handle: session?.handle || account.handle, + email: session?.email || account.email, + emailConfirmed: session?.emailConfirmed || account.emailConfirmed, refreshJwt: session?.refreshJwt, // undefined when expired or creation fails accessJwt: session?.accessJwt, // undefined when expired or creation fails } - logger.debug(`session: BskyAgent.persistSession`, { - expired, - did: refreshedAccount.did, - handle: refreshedAccount.handle, - }) + logger.debug( + `session: BskyAgent.persistSession`, + { + expired, + did: refreshedAccount.did, + handle: refreshedAccount.handle, + }, + logger.DebugContext.session, + ) persistSessionCallback({ expired, @@ -92,11 +106,12 @@ function createPersistSessionHandler( export function Provider({children}: React.PropsWithChildren<{}>) { const [state, setState] = React.useState({ + agent: PUBLIC_BSKY_AGENT, hasSession: false, isInitialLoad: true, // try to resume the session first + isSwitchingAccounts: false, accounts: persisted.get('session').accounts, currentAccount: undefined, // assume logged out to start - agent: PUBLIC_BSKY_AGENT, }) const upsertAccount = React.useCallback( @@ -115,10 +130,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { // TODO have not connected this yet const createAccount = React.useCallback( async ({service, email, password, handle, inviteCode}: any) => { - logger.debug(`session: creating account`, { - service, - handle, - }) + logger.debug( + `session: creating account`, + { + service, + handle, + }, + logger.DebugContext.session, + ) const agent = new BskyAgent({service}) @@ -136,9 +155,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const account: persisted.PersistedAccount = { service, did: agent.session.did, + handle: agent.session.handle, + email: agent.session.email!, // TODO this is always defined? + emailConfirmed: false, refreshJwt: agent.session.refreshJwt, accessJwt: agent.session.accessJwt, - handle: agent.session.handle, } agent.setPersistSessionHandler( @@ -149,20 +170,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) { upsertAccount(account) - logger.debug(`session: created account`, { - service, - handle, - }) + logger.debug( + `session: created account`, + { + service, + handle, + }, + logger.DebugContext.session, + ) }, [upsertAccount], ) const login = React.useCallback( async ({service, identifier, password}) => { - logger.debug(`session: login`, { - service, - identifier, - }) + logger.debug( + `session: login`, + { + service, + identifier, + }, + logger.DebugContext.session, + ) const agent = new BskyAgent({service}) @@ -175,9 +204,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const account: persisted.PersistedAccount = { service, 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, - handle: agent.session.handle, } agent.setPersistSessionHandler( @@ -189,16 +220,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) { setState(s => ({...s, agent})) upsertAccount(account) - logger.debug(`session: logged in`, { - service, - identifier, - }) + logger.debug( + `session: logged in`, + { + service, + identifier, + }, + logger.DebugContext.session, + ) }, [upsertAccount], ) const logout = React.useCallback(async () => { - logger.debug(`session: logout`) + logger.debug(`session: logout`, {}, logger.DebugContext.session) setState(s => { return { ...s, @@ -215,10 +250,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const initSession = React.useCallback( async account => { - logger.debug(`session: initSession`, { - did: account.did, - handle: account.handle, - }) + logger.debug( + `session: initSession`, + { + did: account.did, + handle: account.handle, + }, + logger.DebugContext.session, + ) const agent = new BskyAgent({ service: account.service, @@ -289,19 +328,50 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const updatedAccount = { ...currentAccount, - handle: account.handle, // only update handle rn + handle: account.handle || currentAccount.handle, + email: account.email || currentAccount.email, + emailConfirmed: + account.emailConfirmed !== undefined + ? account.emailConfirmed + : currentAccount.emailConfirmed, } return { ...s, currentAccount: updatedAccount, - accounts: s.accounts.filter(a => a.did !== currentAccount.did), + accounts: [ + updatedAccount, + ...s.accounts.filter(a => a.did !== currentAccount.did), + ], } }) }, [setState], ) + const selectAccount = React.useCallback( + async account => { + setState(s => ({...s, isSwitchingAccounts: true})) + try { + await initSession(account) + setState(s => ({...s, isSwitchingAccounts: false})) + } catch (e) { + // reset this in case of error + setState(s => ({...s, isSwitchingAccounts: false})) + // but other listeners need a throw + throw e + } + }, + [setState, initSession], + ) + + const clearCurrentAccount = React.useCallback(() => { + setState(s => ({ + ...s, + currentAccount: undefined, + })) + }, [setState]) + React.useEffect(() => { persisted.write('session', { accounts: state.accounts, @@ -313,28 +383,36 @@ export function Provider({children}: React.PropsWithChildren<{}>) { return persisted.onUpdate(() => { const session = persisted.get('session') - logger.debug(`session: onUpdate`) + logger.debug(`session: onUpdate`, {}, logger.DebugContext.session) if (session.currentAccount) { if (session.currentAccount?.did !== state.currentAccount?.did) { - logger.debug(`session: switching account`, { - from: { - did: state.currentAccount?.did, - handle: state.currentAccount?.handle, + logger.debug( + `session: switching account`, + { + from: { + did: state.currentAccount?.did, + handle: state.currentAccount?.handle, + }, + to: { + did: session.currentAccount.did, + handle: session.currentAccount.handle, + }, }, - to: { - did: session.currentAccount.did, - handle: session.currentAccount.handle, - }, - }) + logger.DebugContext.session, + ) initSession(session.currentAccount) } } else if (!session.currentAccount && state.currentAccount) { - logger.debug(`session: logging out`, { - did: state.currentAccount?.did, - handle: state.currentAccount?.handle, - }) + logger.debug( + `session: logging out`, + { + did: state.currentAccount?.did, + handle: state.currentAccount?.handle, + }, + logger.DebugContext.session, + ) logout() } @@ -357,7 +435,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { initSession, resumeSession, removeAccount, + selectAccount, updateCurrentAccount, + clearCurrentAccount, }), [ createAccount, @@ -366,7 +446,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { initSession, resumeSession, removeAccount, + selectAccount, updateCurrentAccount, + clearCurrentAccount, ], ) diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index 3e2c9c1b..0d817296 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -6,7 +6,6 @@ import {CreateAccount} from 'view/com/auth/create/CreateAccount' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {useAnalytics} from 'lib/analytics/analytics' import {SplashScreen} from './SplashScreen' import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' @@ -19,7 +18,6 @@ enum ScreenState { export const LoggedOut = observer(function LoggedOutImpl() { const pal = usePalette('default') - const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() const {screen} = useAnalytics() const [screenState, setScreenState] = React.useState( @@ -31,10 +29,7 @@ export const LoggedOut = observer(function LoggedOutImpl() { setMinimalShellMode(true) }, [screen, setMinimalShellMode]) - if ( - store.session.isResumingSession || - screenState === ScreenState.S_LoginOrCreateAccount - ) { + if (screenState === ScreenState.S_LoginOrCreateAccount) { return ( setScreenState(ScreenState.S_Login)} diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 8e2bbed8..0f56755d 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -18,6 +18,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useOnboardingDispatch} from '#/state/shell' +import {useSessionApi} from '#/state/session' import {Step1} from './Step1' import {Step2} from './Step2' @@ -34,6 +35,7 @@ export const CreateAccount = observer(function CreateAccountImpl({ const model = React.useMemo(() => new CreateAccountModel(store), [store]) const {_} = useLingui() const onboardingDispatch = useOnboardingDispatch() + const {createAccount} = useSessionApi() React.useEffect(() => { screen('CreateAccount') @@ -64,14 +66,18 @@ export const CreateAccount = observer(function CreateAccountImpl({ model.next() } else { try { - await model.submit(onboardingDispatch) + console.log('BEFORE') + await model.submit({ + onboardingDispatch, + createAccount, + }) } catch { // dont need to handle here } finally { track('Try Create Account') } } - }, [model, track, onboardingDispatch]) + }, [model, track, onboardingDispatch, createAccount]) return ( void +}) { + const pal = usePalette('default') + const {_} = useLingui() + const {isError, data} = useGetProfile({did: account.did}) + + const onPress = React.useCallback(() => { + onSelect(account) + }, [account, onSelect]) + + if (isError) return null + + return ( + + + + + + + + {data?.displayName || account.handle}{' '} + + + {account.handle} + + + + + + ) +} export const ChooseAccountForm = ({ - store, onSelectAccount, onPressBack, }: { - store: RootStoreModel onSelectAccount: (account?: AccountData) => void onPressBack: () => void }) => { const {track, screen} = useAnalytics() const pal = usePalette('default') - const [isProcessing, setIsProcessing] = React.useState(false) const {_} = useLingui() + const {accounts} = useSession() + const {initSession} = useSessionApi() React.useEffect(() => { screen('Choose Account') }, [screen]) - const onTryAccount = async (account: AccountData) => { - if (account.accessJwt && account.refreshJwt) { - setIsProcessing(true) - if (await store.session.resumeSession(account)) { + const onSelect = React.useCallback( + async (account: SessionAccount) => { + if (account.accessJwt) { + await initSession(account) track('Sign In', {resumedSession: true}) - setIsProcessing(false) - return + } else { + onSelectAccount(account) } - setIsProcessing(false) - } - onSelectAccount(account) - } + }, + [track, initSession, onSelectAccount], + ) return ( @@ -55,35 +96,8 @@ export const ChooseAccountForm = ({ style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}> Sign in as... - {store.session.accounts.map(account => ( - onTryAccount(account)} - accessibilityRole="button" - accessibilityLabel={_(msg`Sign in as ${account.handle}`)} - accessibilityHint="Double tap to sign in"> - - - - - - - {account.displayName || account.handle}{' '} - - - {account.handle} - - - - - + {accounts.map(account => ( + ))} - {isProcessing && } ) diff --git a/src/view/com/auth/login/ForgotPasswordForm.tsx b/src/view/com/auth/login/ForgotPasswordForm.tsx index 9bfab18b..a794665c 100644 --- a/src/view/com/auth/login/ForgotPasswordForm.tsx +++ b/src/view/com/auth/login/ForgotPasswordForm.tsx @@ -15,7 +15,6 @@ import {useAnalytics} from 'lib/analytics/analytics' import {Text} from '../../util/text/Text' import {s} from 'lib/styles' import {toNiceDomain} from 'lib/strings/url-helpers' -import {RootStoreModel} from 'state/index' import {ServiceDescription} from 'state/models/session' import {isNetworkError} from 'lib/strings/errors' import {usePalette} from 'lib/hooks/usePalette' @@ -36,7 +35,6 @@ export const ForgotPasswordForm = ({ onPressBack, onEmailSent, }: { - store: RootStoreModel error: string serviceUrl: string serviceDescription: ServiceDescription | undefined diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx index 401b7d98..de00d6ca 100644 --- a/src/view/com/auth/login/Login.tsx +++ b/src/view/com/auth/login/Login.tsx @@ -14,6 +14,7 @@ import {SetNewPasswordForm} from './SetNewPasswordForm' import {PasswordUpdatedForm} from './PasswordUpdatedForm' import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' +import {useSession} from '#/state/session' enum Forms { Login, @@ -26,6 +27,7 @@ enum Forms { export const Login = ({onPressBack}: {onPressBack: () => void}) => { const pal = usePalette('default') const store = useStores() + const {accounts} = useSession() const {track} = useAnalytics() const {_} = useLingui() const [error, setError] = useState('') @@ -36,7 +38,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { >(undefined) const [initialHandle, setInitialHandle] = useState('') const [currentForm, setCurrentForm] = useState( - store.session.hasAccounts ? Forms.ChooseAccount : Forms.Login, + accounts.length ? Forms.ChooseAccount : Forms.Login, ) const onSelectAccount = (account?: AccountData) => { @@ -95,7 +97,6 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { title={_(msg`Sign in`)} description={_(msg`Enter your username and password`)}> void}) => { title={_(msg`Sign in as...`)} description={_(msg`Select from an existing account`)}> @@ -126,7 +126,6 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { title={_(msg`Forgot Password`)} description={_(msg`Let's get your password reset!`)}> void}) => { title={_(msg`Forgot Password`)} description={_(msg`Let's get your password reset!`)}> void diff --git a/src/view/com/auth/withAuthRequired.tsx b/src/view/com/auth/withAuthRequired.tsx index 898f8105..4b8b31d6 100644 --- a/src/view/com/auth/withAuthRequired.tsx +++ b/src/view/com/auth/withAuthRequired.tsx @@ -6,7 +6,6 @@ import { TouchableOpacity, } from 'react-native' import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' import {CenteredView} from '../util/Views' import {LoggedOut} from './LoggedOut' import {Onboarding} from './Onboarding' @@ -14,17 +13,18 @@ import {Text} from '../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {STATUS_PAGE_URL} from 'lib/constants' import {useOnboardingState} from '#/state/shell' +import {useSession} from '#/state/session' export const withAuthRequired =

( Component: React.ComponentType

, ): React.FC

=> observer(function AuthRequired(props: P) { - const store = useStores() + const {isInitialLoad, hasSession} = useSession() const onboardingState = useOnboardingState() - if (store.session.isResumingSession) { + if (isInitialLoad) { return } - if (!store.session.hasSession) { + if (!hasSession) { return } if (onboardingState.isActive) { diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx index 710c0588..6f7a9210 100644 --- a/src/view/com/modals/ChangeEmail.tsx +++ b/src/view/com/modals/ChangeEmail.tsx @@ -6,7 +6,6 @@ import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import {ErrorMessage} from '../util/error/ErrorMessage' import * as Toast from '../util/Toast' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' @@ -15,6 +14,7 @@ import {cleanError} from 'lib/strings/errors' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useModalControls} from '#/state/modals' +import {useSession, useSessionApi} from '#/state/session' enum Stages { InputEmail, @@ -26,12 +26,11 @@ export const snapPoints = ['90%'] export const Component = observer(function Component({}: {}) { const pal = usePalette('default') - const store = useStores() + const {agent, currentAccount} = useSession() + const {updateCurrentAccount} = useSessionApi() const {_} = useLingui() const [stage, setStage] = useState(Stages.InputEmail) - const [email, setEmail] = useState( - store.session.currentSession?.email || '', - ) + const [email, setEmail] = useState(currentAccount?.email || '') const [confirmationCode, setConfirmationCode] = useState('') const [isProcessing, setIsProcessing] = useState(false) const [error, setError] = useState('') @@ -39,19 +38,19 @@ export const Component = observer(function Component({}: {}) { const {openModal, closeModal} = useModalControls() const onRequestChange = async () => { - if (email === store.session.currentSession?.email) { + if (email === currentAccount?.email) { setError('Enter your new email above') return } setError('') setIsProcessing(true) try { - const res = await store.agent.com.atproto.server.requestEmailUpdate() + const res = await agent.com.atproto.server.requestEmailUpdate() if (res.data.tokenRequired) { setStage(Stages.ConfirmCode) } else { - await store.agent.com.atproto.server.updateEmail({email: email.trim()}) - store.session.updateLocalAccountData({ + await agent.com.atproto.server.updateEmail({email: email.trim()}) + updateCurrentAccount({ email: email.trim(), emailConfirmed: false, }) @@ -79,11 +78,11 @@ export const Component = observer(function Component({}: {}) { setError('') setIsProcessing(true) try { - await store.agent.com.atproto.server.updateEmail({ + await agent.com.atproto.server.updateEmail({ email: email.trim(), token: confirmationCode.trim(), }) - store.session.updateLocalAccountData({ + updateCurrentAccount({ email: email.trim(), emailConfirmed: false, }) @@ -120,8 +119,8 @@ export const Component = observer(function Component({}: {}) { ) : stage === Stages.ConfirmCode ? ( An email has been sent to your previous address,{' '} - {store.session.currentSession?.email || ''}. It includes a - confirmation code which you can enter below. + {currentAccount?.email || ''}. It includes a confirmation code + which you can enter below. ) : ( diff --git a/src/view/com/modals/SwitchAccount.tsx b/src/view/com/modals/SwitchAccount.tsx index 1d945799..2ff70eea 100644 --- a/src/view/com/modals/SwitchAccount.tsx +++ b/src/view/com/modals/SwitchAccount.tsx @@ -6,7 +6,6 @@ import { View, } from 'react-native' import {Text} from '../util/text/Text' -import {useStores} from 'state/index' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' @@ -19,26 +18,94 @@ import {BottomSheetScrollView} from '@gorhom/bottom-sheet' import {Haptics} from 'lib/haptics' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useSession, useSessionApi, SessionAccount} from '#/state/session' +import {useGetProfile} from '#/data/useGetProfile' export const snapPoints = ['40%', '90%'] +function SwitchAccountCard({account}: {account: SessionAccount}) { + const pal = usePalette('default') + const {_} = useLingui() + const {track} = useAnalytics() + const {isSwitchingAccounts, currentAccount} = useSession() + const {logout} = useSessionApi() + const {isError, data: profile} = useGetProfile({did: account.did}) + const isCurrentAccount = account.did === currentAccount?.did + const {onPressSwitchAccount} = useAccountSwitcher() + + const onPressSignout = React.useCallback(() => { + track('Settings:SignOutButtonClicked') + logout() + }, [track, logout]) + + // TODO + if (isError || !currentAccount) return null + + const contents = ( + + + + + + + {profile?.displayName || currentAccount.handle} + + + {currentAccount.handle} + + + + {isCurrentAccount ? ( + + + Sign out + + + ) : ( + + )} + + ) + + return isCurrentAccount ? ( + + {contents} + + ) : ( + onPressSwitchAccount(account) + } + accessibilityRole="button" + accessibilityLabel={`Switch to ${account.handle}`} + accessibilityHint="Switches the account you are logged in to"> + {contents} + + ) +} + export function Component({}: {}) { const pal = usePalette('default') - const {track} = useAnalytics() - const {_: _lingui} = useLingui() - - const store = useStores() - const [isSwitching, _, onPressSwitchAccount] = useAccountSwitcher() + const {isSwitchingAccounts, currentAccount, accounts} = useSession() React.useEffect(() => { Haptics.default() }) - const onPressSignout = React.useCallback(() => { - track('Settings:SignOutButtonClicked') - store.session.logout() - }, [track, store]) - return ( Switch Account - {isSwitching ? ( + + {isSwitchingAccounts || !currentAccount ? ( ) : ( - - - - - - - - {store.me.displayName || store.me.handle} - - - {store.me.handle} - - - - - Sign out - - - - + )} - {store.session.switchableAccounts.map(account => ( - onPressSwitchAccount(account) - } - accessibilityRole="button" - accessibilityLabel={`Switch to ${account.handle}`} - accessibilityHint="Switches the account you are logged in to"> - - - - - - {account.displayName || account.handle} - - - {account.handle} - - - - - ))} + + {accounts + .filter(a => a.did !== currentAccount?.did) + .map(account => ( + + ))} ) } diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx index e48e0e4a..106e05b8 100644 --- a/src/view/com/modals/VerifyEmail.tsx +++ b/src/view/com/modals/VerifyEmail.tsx @@ -14,7 +14,6 @@ import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import {ErrorMessage} from '../util/error/ErrorMessage' import * as Toast from '../util/Toast' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' @@ -23,6 +22,7 @@ import {cleanError} from 'lib/strings/errors' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useModalControls} from '#/state/modals' +import {useSession, useSessionApi} from '#/state/session' export const snapPoints = ['90%'] @@ -38,7 +38,8 @@ export const Component = observer(function Component({ showReminder?: boolean }) { const pal = usePalette('default') - const store = useStores() + const {agent, currentAccount} = useSession() + const {updateCurrentAccount} = useSessionApi() const {_} = useLingui() const [stage, setStage] = useState( showReminder ? Stages.Reminder : Stages.Email, @@ -53,7 +54,7 @@ export const Component = observer(function Component({ setError('') setIsProcessing(true) try { - await store.agent.com.atproto.server.requestEmailConfirmation() + await agent.com.atproto.server.requestEmailConfirmation() setStage(Stages.ConfirmCode) } catch (e) { setError(cleanError(String(e))) @@ -66,11 +67,11 @@ export const Component = observer(function Component({ setError('') setIsProcessing(true) try { - await store.agent.com.atproto.server.confirmEmail({ - email: (store.session.currentSession?.email || '').trim(), + await agent.com.atproto.server.confirmEmail({ + email: (currentAccount?.email || '').trim(), token: confirmationCode.trim(), }) - store.session.updateLocalAccountData({emailConfirmed: true}) + updateCurrentAccount({emailConfirmed: true}) Toast.show('Email verified') closeModal() } catch (e) { @@ -112,9 +113,8 @@ export const Component = observer(function Component({ ) : stage === Stages.ConfirmCode ? ( - An email has been sent to{' '} - {store.session.currentSession?.email || ''}. It includes a - confirmation code which you can enter below. + An email has been sent to {currentAccount?.email || ''}. It + includes a confirmation code which you can enter below. ) : ( '' @@ -130,7 +130,7 @@ export const Component = observer(function Component({ size={16} /> - {store.session.currentSession?.email || ''} + {currentAccount?.email || ''} + + + + + + {data?.displayName || account.handle} + + + {account.handle} + + + + {isCurrentAccount ? ( + + + Sign out + + + ) : ( + + )} + + ) + + return isCurrentAccount ? ( + + {contents} + + ) : ( + onPressSwitchAccount(account) + } + accessibilityRole="button" + accessibilityLabel={`Switch to ${account.handle}`} + accessibilityHint="Switches the account you are logged in to"> + {contents} + + ) +} + type Props = NativeStackScreenProps export const SettingsScreen = withAuthRequired( observer(function Settings({}: Props) { @@ -82,14 +150,12 @@ export const SettingsScreen = withAuthRequired( const navigation = useNavigation() const {isMobile} = useWebMediaQueries() const {screen, track} = useAnalytics() - const [isSwitching, setIsSwitching, onPressSwitchAccount] = - useAccountSwitcher() const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting( store.agent, ) const {openModal} = useModalControls() - const {logout} = useSessionApi() - const {accounts} = useSession() + const {isSwitchingAccounts, accounts, currentAccount} = useSession() + const {clearCurrentAccount} = useSessionApi() const primaryBg = useCustomPalette({ light: {backgroundColor: colors.blue0}, @@ -120,30 +186,27 @@ export const SettingsScreen = withAuthRequired( track('Settings:AddAccountButtonClicked') navigation.navigate('HomeTab') navigation.dispatch(StackActions.popToTop()) - store.session.clear() - }, [track, navigation, store]) + clearCurrentAccount() + }, [track, navigation, clearCurrentAccount]) const onPressChangeHandle = React.useCallback(() => { track('Settings:ChangeHandleButtonClicked') openModal({ name: 'change-handle', onChanged() { - setIsSwitching(true) store.session.reloadFromServer().then( () => { - setIsSwitching(false) Toast.show('Your handle has been updated') }, err => { logger.error('Failed to reload from server after handle update', { error: err, }) - setIsSwitching(false) }, ) }, }) - }, [track, store, openModal, setIsSwitching]) + }, [track, store, openModal]) const onPressInviteCodes = React.useCallback(() => { track('Settings:InvitecodesButtonClicked') @@ -154,12 +217,6 @@ export const SettingsScreen = withAuthRequired( navigation.navigate('LanguageSettings') }, [navigation]) - const onPressSignout = React.useCallback(() => { - track('Settings:SignOutButtonClicked') - logout() - store.session.logout() - }, [track, store, logout]) - const onPressDeleteAccount = React.useCallback(() => { openModal({name: 'delete-account'}) }, [openModal]) @@ -217,7 +274,7 @@ export const SettingsScreen = withAuthRequired( contentContainerStyle={isMobile && pal.viewLight} scrollIndicatorInsets={{right: 1}}> - {store.session.currentSession !== undefined ? ( + {currentAccount ? ( <> Account @@ -226,7 +283,7 @@ export const SettingsScreen = withAuthRequired( Email:{' '} - {!store.session.emailNeedsConfirmation && ( + {currentAccount.emailConfirmed && ( <> )} - {store.session.currentSession?.email}{' '} + {currentAccount.email}{' '} openModal({name: 'change-email'})}> @@ -255,7 +312,8 @@ export const SettingsScreen = withAuthRequired( - + + {!currentAccount.emailConfirmed && } ) : null} @@ -264,70 +322,29 @@ export const SettingsScreen = withAuthRequired( - {isSwitching ? ( + + {isSwitchingAccounts ? ( ) : ( - - - - - - - - {store.me.displayName || store.me.handle} - - - {store.me.handle} - - - - - Sign out - - - - + )} - {accounts.map(account => ( - onPressSwitchAccount(account) - } - accessibilityRole="button" - accessibilityLabel={`Switch to ${account.handle}`} - accessibilityHint="Switches the account you are logged in to"> - - {/**/} - - - - {/* @ts-ignore */} - {account.displayName || account.handle} - - - {account.handle} - - - - - ))} + + {accounts + .filter(a => a.did !== currentAccount?.did) + .map(account => ( + + ))} + @@ -349,8 +366,12 @@ export const SettingsScreen = withAuthRequired( @@ -427,7 +448,11 @@ export const SettingsScreen = withAuthRequired( @@ -475,8 +508,12 @@ export const SettingsScreen = withAuthRequired( @@ -492,9 +529,15 @@ export const SettingsScreen = withAuthRequired( navigation.navigate('Moderation') + isSwitchingAccounts + ? undefined + : () => navigation.navigate('Moderation') } accessibilityRole="button" accessibilityHint="" @@ -513,7 +556,11 @@ export const SettingsScreen = withAuthRequired( @@ -655,15 +706,10 @@ const EmailConfirmationNotice = observer( function EmailConfirmationNoticeImpl() { const pal = usePalette('default') const palInverted = usePalette('inverted') - const store = useStores() const {_} = useLingui() const {isMobile} = useWebMediaQueries() const {openModal} = useModalControls() - if (!store.session.emailNeedsConfirmation) { - return null - } - return ( diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index b85823b6..3a0c0c95 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -41,18 +41,31 @@ import {router} from '../../../routes' import {makeProfileLink} from 'lib/routes/links' import {useLingui} from '@lingui/react' import {Trans, msg} from '@lingui/macro' +import {useGetProfile} from '#/data/useGetProfile' +import {useSession} from '#/state/session' const ProfileCard = observer(function ProfileCardImpl() { - const store = useStores() + const {currentAccount} = useSession() + const { + isLoading, + isError, + data: profile, + } = useGetProfile({did: currentAccount!.did}) const {isDesktop} = useWebMediaQueries() const size = 48 - return store.me.handle ? ( + + if (isError || !profile || !currentAccount) return null + + return !isLoading ? ( - + ) : ( @@ -255,7 +268,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() { pal.view, pal.border, ]}> - {store.session.hasSession && } + - {store.session.hasSession && ( - - } - iconFilled={ - - } - label="Profile" - /> - )} + + } + iconFilled={ + + } + label="Profile" + /> - {store.session.hasSession && } + ) }) diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index a4b3e574..cb62b4be 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -14,11 +14,13 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {pluralize} from 'lib/strings/helpers' import {formatCount} from 'view/com/util/numeric/format' import {useModalControls} from '#/state/modals' +import {useSession} from '#/state/session' export const DesktopRightNav = observer(function DesktopRightNavImpl() { const store = useStores() const pal = usePalette('default') const palError = usePalette('error') + const {hasSession, currentAccount} = useSession() const {isTablet} = useWebMediaQueries() if (isTablet) { @@ -27,8 +29,8 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() { return ( - {store.session.hasSession && } - {store.session.hasSession && } + {hasSession && } + {hasSession && } {store.session.isSandbox ? ( @@ -42,8 +44,8 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() { type="md" style={pal.link} href={FEEDBACK_FORM_URL({ - email: store.session.currentSession?.email, - handle: store.session.currentSession?.handle, + email: currentAccount!.email, + handle: currentAccount!.handle, })} text="Send feedback" /> diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 498bc11b..75ed0747 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -33,6 +33,7 @@ import { } from '#/state/shell' import {isAndroid} from 'platform/detection' import {useModalControls} from '#/state/modals' +import {useSession} from '#/state/session' const ShellInner = observer(function ShellInnerImpl() { const store = useStores() @@ -57,6 +58,8 @@ const ShellInner = observer(function ShellInnerImpl() { [setIsDrawerOpen], ) const canGoBack = useNavigationState(state => !isStateAtTabRoot(state)) + const {hasSession} = useSession() + React.useEffect(() => { let listener = {remove() {}} if (isAndroid) { @@ -81,9 +84,7 @@ const ShellInner = observer(function ShellInnerImpl() { onOpen={onOpenDrawer} onClose={onCloseDrawer} swipeEdgeWidth={winDim.width / 2} - swipeEnabled={ - !canGoBack && store.session.hasSession && !isDrawerSwipeDisabled - }> + swipeEnabled={!canGoBack && hasSession && !isDrawerSwipeDisabled}> diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 79249952..a74cd126 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -24,6 +24,7 @@ import { useOnboardingState, } from '#/state/shell' import {useModalControls} from '#/state/modals' +import {useSession} from '#/state/session' const ShellInner = observer(function ShellInnerImpl() { const store = useStores() @@ -33,6 +34,8 @@ const ShellInner = observer(function ShellInnerImpl() { const onboardingState = useOnboardingState() const {isDesktop, isMobile} = useWebMediaQueries() const navigator = useNavigation() + const {hasSession} = useSession() + useAuxClick() useEffect(() => { @@ -44,8 +47,7 @@ const ShellInner = observer(function ShellInnerImpl() { }, [navigator, store.shell, setDrawerOpen, closeModal]) const showBottomBar = isMobile && !onboardingState.isActive - const showSideNavs = - !isMobile && store.session.hasSession && !onboardingState.isActive + const showSideNavs = !isMobile && hasSession && !onboardingState.isActive return ( From 2d7b89c6a1fab05c5214068005298cb71468f06a Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 9 Nov 2023 20:39:54 -0600 Subject: [PATCH 2/8] Cleanup getProfile --- src/data/index.ts | 5 +++++ src/data/useGetProfile.ts | 26 +++----------------------- src/state/session/index.tsx | 5 +---- 3 files changed, 9 insertions(+), 27 deletions(-) create mode 100644 src/data/index.ts diff --git a/src/data/index.ts b/src/data/index.ts new file mode 100644 index 00000000..ae3d1595 --- /dev/null +++ b/src/data/index.ts @@ -0,0 +1,5 @@ +import {BskyAgent} from '@atproto/api' + +export const PUBLIC_BSKY_AGENT = new BskyAgent({ + service: 'https://api.bsky.app', +}) diff --git a/src/data/useGetProfile.ts b/src/data/useGetProfile.ts index 58f24a4e..5e0ab907 100644 --- a/src/data/useGetProfile.ts +++ b/src/data/useGetProfile.ts @@ -1,32 +1,12 @@ -import React from 'react' import {useQuery} from '@tanstack/react-query' -import {BskyAgent} from '@atproto/api' -import {useSession} from '#/state/session' +import {PUBLIC_BSKY_AGENT} from '#/data' export function useGetProfile({did}: {did: string}) { - const {accounts} = useSession() - const account = React.useMemo( - () => accounts.find(a => a.did === did), - [did, accounts], - ) - return useQuery({ - enabled: !!account, - queryKey: ['getProfile', account], + queryKey: ['getProfile', did], queryFn: async () => { - if (!account) { - throw new Error(`useGetProfile: local account not found for ${did}`) - } - - const agent = new BskyAgent({ - // needs to be public data, so remap PDS URLs to App View for now - service: account.service.includes('bsky.social') - ? 'https://api.bsky.app' - : account.service, - }) - - const res = await agent.getProfile({actor: did}) + const res = await PUBLIC_BSKY_AGENT.getProfile({actor: did}) return res.data }, }) diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 668d9d8c..a5362ac6 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -4,6 +4,7 @@ import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api' import {networkRetry} from '#/lib/async/retry' import {logger} from '#/logger' import * as persisted from '#/state/persisted' +import {PUBLIC_BSKY_AGENT} from '#/data' export type SessionAccount = persisted.PersistedAccount @@ -43,10 +44,6 @@ export type ApiContext = { clearCurrentAccount: () => void } -export const PUBLIC_BSKY_AGENT = new BskyAgent({ - service: 'https://api.bsky.app', -}) - const StateContext = React.createContext({ agent: PUBLIC_BSKY_AGENT, hasSession: false, From 742f53d1ecfc5060b7e6fc0cea0f8729d2e9b1b5 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 9 Nov 2023 20:46:45 -0600 Subject: [PATCH 3/8] Comments --- src/view/com/auth/create/CreateAccount.tsx | 1 - src/view/com/auth/login/ChooseAccountForm.tsx | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 0f56755d..65f9ba26 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -66,7 +66,6 @@ export const CreateAccount = observer(function CreateAccountImpl({ model.next() } else { try { - console.log('BEFORE') await model.submit({ onboardingDispatch, createAccount, diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx index 38c13ba0..f1503e64 100644 --- a/src/view/com/auth/login/ChooseAccountForm.tsx +++ b/src/view/com/auth/login/ChooseAccountForm.tsx @@ -28,6 +28,7 @@ function AccountItem({ onSelect(account) }, [account, onSelect]) + // TODO if (isError) return null return ( From b0c9cce5c3ea9246fbc2f71ac64c10c5252ec9a4 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 10 Nov 2023 08:46:45 -0600 Subject: [PATCH 4/8] Follow conventions for query, use isDirty flag in session store to avoid unneccessary writes --- src/{data => state/queries}/index.ts | 0 .../queries/profile.ts} | 4 ++-- src/state/session/index.tsx | 20 +++++++++++++------ src/view/com/auth/login/ChooseAccountForm.tsx | 4 ++-- src/view/com/modals/SwitchAccount.tsx | 4 ++-- src/view/screens/Settings.tsx | 4 ++-- src/view/shell/desktop/LeftNav.tsx | 4 ++-- 7 files changed, 24 insertions(+), 16 deletions(-) rename src/{data => state/queries}/index.ts (100%) rename src/{data/useGetProfile.ts => state/queries/profile.ts} (68%) diff --git a/src/data/index.ts b/src/state/queries/index.ts similarity index 100% rename from src/data/index.ts rename to src/state/queries/index.ts diff --git a/src/data/useGetProfile.ts b/src/state/queries/profile.ts similarity index 68% rename from src/data/useGetProfile.ts rename to src/state/queries/profile.ts index 5e0ab907..c2cd1948 100644 --- a/src/data/useGetProfile.ts +++ b/src/state/queries/profile.ts @@ -1,8 +1,8 @@ import {useQuery} from '@tanstack/react-query' -import {PUBLIC_BSKY_AGENT} from '#/data' +import {PUBLIC_BSKY_AGENT} from '#/state/queries' -export function useGetProfile({did}: {did: string}) { +export function useProfileQuery({did}: {did: string}) { return useQuery({ queryKey: ['getProfile', did], queryFn: async () => { diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index a5362ac6..85ae3b52 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -4,7 +4,7 @@ import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api' import {networkRetry} from '#/lib/async/retry' import {logger} from '#/logger' import * as persisted from '#/state/persisted' -import {PUBLIC_BSKY_AGENT} from '#/data' +import {PUBLIC_BSKY_AGENT} from '#/state/queries' export type SessionAccount = persisted.PersistedAccount @@ -102,6 +102,7 @@ function createPersistSessionHandler( } export function Provider({children}: React.PropsWithChildren<{}>) { + const isDirty = React.useRef(false) const [state, setState] = React.useState({ agent: PUBLIC_BSKY_AGENT, hasSession: false, @@ -113,6 +114,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const upsertAccount = React.useCallback( (account: persisted.PersistedAccount, expired = false) => { + isDirty.current = true setState(s => { return { ...s, @@ -124,7 +126,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { [setState], ) - // TODO have not connected this yet const createAccount = React.useCallback( async ({service, email, password, handle, inviteCode}: any) => { logger.debug( @@ -231,6 +232,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const logout = React.useCallback(async () => { logger.debug(`session: logout`, {}, logger.DebugContext.session) + isDirty.current = true setState(s => { return { ...s, @@ -301,6 +303,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const removeAccount = React.useCallback( account => { + isDirty.current = true setState(s => { return { ...s, @@ -317,6 +320,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ApiContext['updateCurrentAccount'] >( account => { + isDirty.current = true setState(s => { const currentAccount = s.currentAccount @@ -363,6 +367,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) const clearCurrentAccount = React.useCallback(() => { + isDirty.current = true setState(s => ({ ...s, currentAccount: undefined, @@ -370,10 +375,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }, [setState]) React.useEffect(() => { - persisted.write('session', { - accounts: state.accounts, - currentAccount: state.currentAccount, - }) + if (isDirty.current) { + persisted.write('session', { + accounts: state.accounts, + currentAccount: state.currentAccount, + }) + isDirty.current = false + } }, [state]) React.useEffect(() => { diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx index f1503e64..add99789 100644 --- a/src/view/com/auth/login/ChooseAccountForm.tsx +++ b/src/view/com/auth/login/ChooseAccountForm.tsx @@ -11,7 +11,7 @@ import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {styles} from './styles' import {useSession, useSessionApi, SessionAccount} from '#/state/session' -import {useGetProfile} from '#/data/useGetProfile' +import {useProfileQuery} from '#/state/queries/profile' function AccountItem({ account, @@ -22,7 +22,7 @@ function AccountItem({ }) { const pal = usePalette('default') const {_} = useLingui() - const {isError, data} = useGetProfile({did: account.did}) + const {isError, data} = useProfileQuery({did: account.did}) const onPress = React.useCallback(() => { onSelect(account) diff --git a/src/view/com/modals/SwitchAccount.tsx b/src/view/com/modals/SwitchAccount.tsx index 2ff70eea..55ba6936 100644 --- a/src/view/com/modals/SwitchAccount.tsx +++ b/src/view/com/modals/SwitchAccount.tsx @@ -19,7 +19,7 @@ import {Haptics} from 'lib/haptics' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useSession, useSessionApi, SessionAccount} from '#/state/session' -import {useGetProfile} from '#/data/useGetProfile' +import {useProfileQuery} from '#/state/queries/profile' export const snapPoints = ['40%', '90%'] @@ -29,7 +29,7 @@ function SwitchAccountCard({account}: {account: SessionAccount}) { const {track} = useAnalytics() const {isSwitchingAccounts, currentAccount} = useSession() const {logout} = useSessionApi() - const {isError, data: profile} = useGetProfile({did: account.did}) + const {isError, data: profile} = useProfileQuery({did: account.did}) const isCurrentAccount = account.did === currentAccount?.did const {onPressSwitchAccount} = useAccountSwitcher() diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 4fd2f2d5..cff92f8f 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -58,7 +58,7 @@ import { useSetRequireAltTextEnabled, } from '#/state/preferences' import {useSession, useSessionApi, SessionAccount} from '#/state/session' -import {useGetProfile} from '#/data/useGetProfile' +import {useProfileQuery} from '#/state/queries/profile' // TEMPORARY (APP-700) // remove after backend testing finishes @@ -72,7 +72,7 @@ function SettingsAccountCard({account}: {account: SessionAccount}) { const pal = usePalette('default') const {isSwitchingAccounts, currentAccount} = useSession() const {logout} = useSessionApi() - const {isError, data} = useGetProfile({did: account.did}) + const {isError, data} = useProfileQuery({did: account.did}) const isCurrentAccount = account.did === currentAccount?.did const {onPressSwitchAccount} = useAccountSwitcher() diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 3a0c0c95..45be67d2 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -41,7 +41,7 @@ import {router} from '../../../routes' import {makeProfileLink} from 'lib/routes/links' import {useLingui} from '@lingui/react' import {Trans, msg} from '@lingui/macro' -import {useGetProfile} from '#/data/useGetProfile' +import {useProfileQuery} from '#/state/queries/profile' import {useSession} from '#/state/session' const ProfileCard = observer(function ProfileCardImpl() { @@ -50,7 +50,7 @@ const ProfileCard = observer(function ProfileCardImpl() { isLoading, isError, data: profile, - } = useGetProfile({did: currentAccount!.did}) + } = useProfileQuery({did: currentAccount!.did}) const {isDesktop} = useWebMediaQueries() const size = 48 From 60386f8f07cde330a0f36df614b04f07b89d72ab Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 10 Nov 2023 08:48:15 -0600 Subject: [PATCH 5/8] Swap order --- src/state/session/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 85ae3b52..dd17284f 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -376,11 +376,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) { React.useEffect(() => { if (isDirty.current) { + isDirty.current = false persisted.write('session', { accounts: state.accounts, currentAccount: state.currentAccount, }) - isDirty.current = false } }, [state]) From 499021229a03b3414d6a1f2909c6e7d7db5e4b2f Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 10 Nov 2023 08:53:23 -0600 Subject: [PATCH 6/8] Remove error states, just best effort --- src/view/com/auth/login/ChooseAccountForm.tsx | 9 +++------ src/view/com/modals/SwitchAccount.tsx | 9 +++------ src/view/screens/Settings.tsx | 11 ++++------- src/view/shell/desktop/LeftNav.tsx | 14 ++++---------- 4 files changed, 14 insertions(+), 29 deletions(-) diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx index add99789..c4a3b158 100644 --- a/src/view/com/auth/login/ChooseAccountForm.tsx +++ b/src/view/com/auth/login/ChooseAccountForm.tsx @@ -22,15 +22,12 @@ function AccountItem({ }) { const pal = usePalette('default') const {_} = useLingui() - const {isError, data} = useProfileQuery({did: account.did}) + const {data: profile} = useProfileQuery({did: account.did}) const onPress = React.useCallback(() => { onSelect(account) }, [account, onSelect]) - // TODO - if (isError) return null - return ( - + - {data?.displayName || account.handle}{' '} + {profile?.displayName || account.handle}{' '} {account.handle} diff --git a/src/view/com/modals/SwitchAccount.tsx b/src/view/com/modals/SwitchAccount.tsx index 55ba6936..05d0da37 100644 --- a/src/view/com/modals/SwitchAccount.tsx +++ b/src/view/com/modals/SwitchAccount.tsx @@ -29,7 +29,7 @@ function SwitchAccountCard({account}: {account: SessionAccount}) { const {track} = useAnalytics() const {isSwitchingAccounts, currentAccount} = useSession() const {logout} = useSessionApi() - const {isError, data: profile} = useProfileQuery({did: account.did}) + const {data: profile} = useProfileQuery({did: account.did}) const isCurrentAccount = account.did === currentAccount?.did const {onPressSwitchAccount} = useAccountSwitcher() @@ -38,9 +38,6 @@ function SwitchAccountCard({account}: {account: SessionAccount}) { logout() }, [track, logout]) - // TODO - if (isError || !currentAccount) return null - const contents = ( @@ -48,10 +45,10 @@ function SwitchAccountCard({account}: {account: SessionAccount}) { - {profile?.displayName || currentAccount.handle} + {profile?.displayName || currentAccount?.handle} - {currentAccount.handle} + {currentAccount?.handle} diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index cff92f8f..e2cd4c9e 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -72,21 +72,18 @@ function SettingsAccountCard({account}: {account: SessionAccount}) { const pal = usePalette('default') const {isSwitchingAccounts, currentAccount} = useSession() const {logout} = useSessionApi() - const {isError, data} = useProfileQuery({did: account.did}) + const {data: profile} = useProfileQuery({did: account.did}) const isCurrentAccount = account.did === currentAccount?.did const {onPressSwitchAccount} = useAccountSwitcher() - // TODO - if (isError || !currentAccount) return null - const contents = ( - + - {data?.displayName || account.handle} + {profile?.displayName || account.handle} {account.handle} @@ -99,7 +96,7 @@ function SettingsAccountCard({account}: {account: SessionAccount}) { onPress={logout} accessibilityRole="button" accessibilityLabel="Sign out" - accessibilityHint={`Signs ${data?.displayName} out of Bluesky`}> + accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}> Sign out diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 45be67d2..0586323b 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -46,21 +46,15 @@ import {useSession} from '#/state/session' const ProfileCard = observer(function ProfileCardImpl() { const {currentAccount} = useSession() - const { - isLoading, - isError, - data: profile, - } = useProfileQuery({did: currentAccount!.did}) + const {isLoading, data: profile} = useProfileQuery({did: currentAccount!.did}) const {isDesktop} = useWebMediaQueries() const size = 48 - if (isError || !profile || !currentAccount) return null - - return !isLoading ? ( + return !isLoading && profile ? ( Date: Fri, 10 Nov 2023 08:59:39 -0600 Subject: [PATCH 7/8] Clean up isDirty handling --- src/state/session/index.tsx | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index dd17284f..90948b01 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -112,10 +112,17 @@ export function Provider({children}: React.PropsWithChildren<{}>) { currentAccount: undefined, // assume logged out to start }) + const setStateAndPersist = React.useCallback( + (fn: (prev: StateContext) => StateContext) => { + isDirty.current = true + setState(fn) + }, + [setState], + ) + const upsertAccount = React.useCallback( (account: persisted.PersistedAccount, expired = false) => { - isDirty.current = true - setState(s => { + setStateAndPersist(s => { return { ...s, currentAccount: expired ? undefined : account, @@ -123,7 +130,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } }) }, - [setState], + [setStateAndPersist], ) const createAccount = React.useCallback( @@ -232,8 +239,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const logout = React.useCallback(async () => { logger.debug(`session: logout`, {}, logger.DebugContext.session) - isDirty.current = true - setState(s => { + setStateAndPersist(s => { return { ...s, agent: PUBLIC_BSKY_AGENT, @@ -245,7 +251,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { })), } }) - }, [setState]) + }, [setStateAndPersist]) const initSession = React.useCallback( async account => { @@ -303,8 +309,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const removeAccount = React.useCallback( account => { - isDirty.current = true - setState(s => { + setStateAndPersist(s => { return { ...s, accounts: s.accounts.filter( @@ -313,15 +318,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } }) }, - [setState], + [setStateAndPersist], ) const updateCurrentAccount = React.useCallback< ApiContext['updateCurrentAccount'] >( account => { - isDirty.current = true - setState(s => { + setStateAndPersist(s => { const currentAccount = s.currentAccount // ignore, should never happen @@ -347,7 +351,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } }) }, - [setState], + [setStateAndPersist], ) const selectAccount = React.useCallback( @@ -367,12 +371,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) const clearCurrentAccount = React.useCallback(() => { - isDirty.current = true - setState(s => ({ + setStateAndPersist(s => ({ ...s, currentAccount: undefined, })) - }, [setState]) + }, [setStateAndPersist]) React.useEffect(() => { if (isDirty.current) { From 436a14eabb4fe2238ff6048f41042433c0e07268 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 10 Nov 2023 09:59:04 -0600 Subject: [PATCH 8/8] Pare down session as much as possible --- src/lib/constants.ts | 5 +- src/state/models/root-store.ts | 20 +- src/state/models/session.ts | 443 +----------------- src/state/session/index.tsx | 36 +- src/view/com/auth/login/ChooseAccountForm.tsx | 3 +- src/view/com/auth/login/Login.tsx | 5 +- src/view/com/feeds/FeedPage.tsx | 15 +- src/view/com/modals/SwitchAccount.tsx | 2 +- src/view/com/pager/FeedsTabBarMobile.tsx | 4 +- src/view/com/testing/TestCtrls.e2e.tsx | 8 +- src/view/com/util/AccountDropdownBtn.tsx | 6 +- src/view/com/util/PostSandboxWarning.tsx | 6 +- src/view/screens/Settings.tsx | 2 +- src/view/shell/Drawer.tsx | 98 ++-- src/view/shell/desktop/RightNav.tsx | 5 +- 15 files changed, 126 insertions(+), 532 deletions(-) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 472b59d7..89c441e9 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -43,7 +43,10 @@ export function IS_PROD(url: string) { // until open federation, "production" is defined as the main server // this definition will not work once federation is enabled! // -prf - return url.startsWith('https://bsky.social') + return ( + url.startsWith('https://bsky.social') || + url.startsWith('https://api.bsky.app') + ) } export const PROD_TEAM_HANDLES = [ diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index d11e9a14..4085a52c 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -63,7 +63,6 @@ export class RootStoreModel { serialize(): unknown { return { appInfo: this.appInfo, - session: this.session.serialize(), me: this.me.serialize(), preferences: this.preferences.serialize(), } @@ -80,9 +79,6 @@ export class RootStoreModel { if (hasProp(v, 'me')) { this.me.hydrate(v.me) } - if (hasProp(v, 'session')) { - this.session.hydrate(v.session) - } if (hasProp(v, 'preferences')) { this.preferences.hydrate(v.preferences) } @@ -92,18 +88,7 @@ export class RootStoreModel { /** * Called during init to resume any stored session. */ - async attemptSessionResumption() { - logger.debug('RootStoreModel:attemptSessionResumption') - try { - await this.session.attemptSessionResumption() - logger.debug('Session initialized', { - hasSession: this.session.hasSession, - }) - this.updateSessionState() - } catch (e: any) { - logger.warn('Failed to initialize session', {error: e}) - } - } + async attemptSessionResumption() {} /** * Called by the session model. Refreshes session-oriented state. @@ -135,11 +120,10 @@ export class RootStoreModel { } /** - * Clears all session-oriented state. + * Clears all session-oriented state, previously called on LOGOUT */ clearAllSessionState() { logger.debug('RootStoreModel:clearAllSessionState') - this.session.clear() resetToTab('HomeTab') this.me.clear() } diff --git a/src/state/models/session.ts b/src/state/models/session.ts index 5b95c7d3..2c66cfdf 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -1,274 +1,26 @@ -import {makeAutoObservable, runInAction} from 'mobx' +import {makeAutoObservable} from 'mobx' import { BskyAgent, - AtpSessionEvent, - AtpSessionData, ComAtprotoServerDescribeServer as DescribeServer, } from '@atproto/api' -import normalizeUrl from 'normalize-url' -import {isObj, hasProp} from 'lib/type-guards' -import {networkRetry} from 'lib/async/retry' -import {z} from 'zod' import {RootStoreModel} from './root-store' -import {IS_PROD} from 'lib/constants' -import {track} from 'lib/analytics/analytics' -import {logger} from '#/logger' export type ServiceDescription = DescribeServer.OutputSchema -export const activeSession = z.object({ - service: z.string(), - did: z.string(), -}) -export type ActiveSession = z.infer - -export const accountData = z.object({ - service: z.string(), - refreshJwt: z.string().optional(), - accessJwt: z.string().optional(), - handle: z.string(), - did: z.string(), - email: z.string().optional(), - displayName: z.string().optional(), - aviUrl: z.string().optional(), - emailConfirmed: z.boolean().optional(), -}) -export type AccountData = z.infer - -interface AdditionalAccountData { - displayName?: string - aviUrl?: string -} - export class SessionModel { - // DEBUG - // emergency log facility to help us track down this logout issue - // remove when resolved - // -prf - _log(message: string, details?: Record) { - details = details || {} - details.state = { - data: this.data, - accounts: this.accounts.map( - a => - `${!!a.accessJwt && !!a.refreshJwt ? '✅' : '❌'} ${a.handle} (${ - a.service - })`, - ), - isResumingSession: this.isResumingSession, - } - logger.debug(message, details, logger.DebugContext.session) - } - - /** - * Currently-active session - */ - data: ActiveSession | null = null - /** - * A listing of the currently & previous sessions - */ - accounts: AccountData[] = [] - /** - * Flag to indicate if we're doing our initial-load session resumption - */ - isResumingSession = false - constructor(public rootStore: RootStoreModel) { makeAutoObservable(this, { rootStore: false, - serialize: false, - hydrate: false, hasSession: false, }) } - get currentSession() { - if (!this.data) { - return undefined - } - const {did, service} = this.data - return this.accounts.find( - account => - normalizeUrl(account.service) === normalizeUrl(service) && - account.did === did && - !!account.accessJwt && - !!account.refreshJwt, - ) + get currentSession(): any { + return undefined } get hasSession() { - return !!this.currentSession && !!this.rootStore.agent.session - } - - get hasAccounts() { - return this.accounts.length >= 1 - } - - get switchableAccounts() { - return this.accounts.filter(acct => acct.did !== this.data?.did) - } - - get emailNeedsConfirmation() { - return !this.currentSession?.emailConfirmed - } - - get isSandbox() { - if (!this.data) { - return false - } - return !IS_PROD(this.data.service) - } - - serialize(): unknown { - return { - data: this.data, - accounts: this.accounts, - } - } - - hydrate(v: unknown) { - this.accounts = [] - if (isObj(v)) { - if (hasProp(v, 'data') && activeSession.safeParse(v.data)) { - this.data = v.data as ActiveSession - } - if (hasProp(v, 'accounts') && Array.isArray(v.accounts)) { - for (const account of v.accounts) { - if (accountData.safeParse(account)) { - this.accounts.push(account as AccountData) - } - } - } - } - } - - clear() { - this.data = null - } - - /** - * Attempts to resume the previous session loaded from storage - */ - async attemptSessionResumption() { - const sess = this.currentSession - if (sess) { - this._log('SessionModel:attemptSessionResumption found stored session') - this.isResumingSession = true - try { - return await this.resumeSession(sess) - } finally { - runInAction(() => { - this.isResumingSession = false - }) - } - } else { - this._log( - 'SessionModel:attemptSessionResumption has no session to resume', - ) - } - } - - /** - * Sets the active session - */ - async setActiveSession(agent: BskyAgent, did: string) { - this._log('SessionModel:setActiveSession') - const hadSession = !!this.data - this.data = { - service: agent.service.toString(), - did, - } - await this.rootStore.handleSessionChange(agent, {hadSession}) - } - - /** - * Upserts a session into the accounts - */ - persistSession( - service: string, - did: string, - event: AtpSessionEvent, - session?: AtpSessionData, - addedInfo?: AdditionalAccountData, - ) { - this._log('SessionModel:persistSession', { - service, - did, - event, - hasSession: !!session, - }) - - const existingAccount = this.accounts.find( - account => account.service === service && account.did === did, - ) - - // fall back to any preexisting access tokens - let refreshJwt = session?.refreshJwt || existingAccount?.refreshJwt - let accessJwt = session?.accessJwt || existingAccount?.accessJwt - if (event === 'expired') { - // only clear the tokens when they're known to have expired - refreshJwt = undefined - accessJwt = undefined - } - - const newAccount = { - service, - did, - refreshJwt, - accessJwt, - - handle: session?.handle || existingAccount?.handle || '', - email: session?.email || existingAccount?.email || '', - displayName: addedInfo - ? addedInfo.displayName - : existingAccount?.displayName || '', - aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '', - emailConfirmed: session?.emailConfirmed, - } - if (!existingAccount) { - this.accounts.push(newAccount) - } else { - this.accounts = [ - newAccount, - ...this.accounts.filter( - account => !(account.service === service && account.did === did), - ), - ] - } - - // if the session expired, fire an event to let the user know - if (event === 'expired') { - this.rootStore.handleSessionDrop() - } - } - - /** - * Clears any session tokens from the accounts; used on logout. - */ - clearSessionTokens() { - this._log('SessionModel:clearSessionTokens') - this.accounts = this.accounts.map(acct => ({ - service: acct.service, - handle: acct.handle, - did: acct.did, - displayName: acct.displayName, - aviUrl: acct.aviUrl, - email: acct.email, - emailConfirmed: acct.emailConfirmed, - })) - } - - /** - * Fetches additional information about an account on load. - */ - async loadAccountInfo(agent: BskyAgent, did: string) { - const res = await agent.getProfile({actor: did}).catch(_e => undefined) - if (res) { - return { - displayName: res.data.displayName, - aviUrl: res.data.avatar, - } - } + return false } /** @@ -280,193 +32,8 @@ export class SessionModel { return res.data } - /** - * Attempt to resume a session that we still have access tokens for. - */ - async resumeSession(account: AccountData): Promise { - this._log('SessionModel:resumeSession') - if (!(account.accessJwt && account.refreshJwt && account.service)) { - this._log( - 'SessionModel:resumeSession aborted due to lack of access tokens', - ) - return false - } - - const agent = new BskyAgent({ - service: account.service, - persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => { - this.persistSession(account.service, account.did, evt, sess) - }, - }) - - try { - await networkRetry(3, () => - agent.resumeSession({ - accessJwt: account.accessJwt || '', - refreshJwt: account.refreshJwt || '', - did: account.did, - handle: account.handle, - email: account.email, - emailConfirmed: account.emailConfirmed, - }), - ) - const addedInfo = await this.loadAccountInfo(agent, account.did) - this.persistSession( - account.service, - account.did, - 'create', - agent.session, - addedInfo, - ) - this._log('SessionModel:resumeSession succeeded') - } catch (e: any) { - this._log('SessionModel:resumeSession failed', { - error: e.toString(), - }) - return false - } - - await this.setActiveSession(agent, account.did) - return true - } - - /** - * Create a new session. - */ - async login({ - service, - identifier, - password, - }: { - service: string - identifier: string - password: string - }) { - this._log('SessionModel:login') - const agent = new BskyAgent({service}) - await agent.login({identifier, password}) - if (!agent.session) { - throw new Error('Failed to establish session') - } - - const did = agent.session.did - const addedInfo = await this.loadAccountInfo(agent, did) - - this.persistSession(service, did, 'create', agent.session, addedInfo) - agent.setPersistSessionHandler( - (evt: AtpSessionEvent, sess?: AtpSessionData) => { - this.persistSession(service, did, evt, sess) - }, - ) - - await this.setActiveSession(agent, did) - this._log('SessionModel:login succeeded') - } - - async createAccount({ - service, - email, - password, - handle, - inviteCode, - }: { - service: string - email: string - password: string - handle: string - inviteCode?: string - }) { - this._log('SessionModel:createAccount') - const agent = new BskyAgent({service}) - await agent.createAccount({ - handle, - password, - email, - inviteCode, - }) - if (!agent.session) { - throw new Error('Failed to establish session') - } - - const did = agent.session.did - const addedInfo = await this.loadAccountInfo(agent, did) - - this.persistSession(service, did, 'create', agent.session, addedInfo) - agent.setPersistSessionHandler( - (evt: AtpSessionEvent, sess?: AtpSessionData) => { - this.persistSession(service, did, evt, sess) - }, - ) - - await this.setActiveSession(agent, did) - this._log('SessionModel:createAccount succeeded') - track('Create Account Successfully') - } - - /** - * Close all sessions across all accounts. - */ - async logout() { - this._log('SessionModel:logout') - // TODO - // need to evaluate why deleting the session has caused errors at times - // -prf - /*if (this.hasSession) { - this.rootStore.agent.com.atproto.session.delete().catch((e: any) => { - this.rootStore.log.warn( - '(Minor issue) Failed to delete session on the server', - e, - ) - }) - }*/ - 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.agent - .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() - } - } - - updateLocalAccountData(changes: Partial) { - this.accounts = this.accounts.map(acct => - acct.did === this.data?.did ? {...acct, ...changes} : acct, - ) - } + async reloadFromServer() {} } diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 90948b01..d0ca1013 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -1,20 +1,25 @@ import React from 'react' +import {DeviceEventEmitter} from 'react-native' import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api' import {networkRetry} from '#/lib/async/retry' import {logger} from '#/logger' import * as persisted from '#/state/persisted' import {PUBLIC_BSKY_AGENT} from '#/state/queries' +import {IS_PROD} from '#/lib/constants' export type SessionAccount = persisted.PersistedAccount -export type StateContext = { +export type SessionState = { agent: BskyAgent isInitialLoad: boolean isSwitchingAccounts: boolean accounts: persisted.PersistedAccount[] currentAccount: persisted.PersistedAccount | undefined +} +export type StateContext = SessionState & { hasSession: boolean + isSandbox: boolean } export type ApiContext = { createAccount: (props: { @@ -30,15 +35,13 @@ export type ApiContext = { password: string }) => Promise logout: () => Promise - initSession: (account: persisted.PersistedAccount) => Promise - resumeSession: (account?: persisted.PersistedAccount) => Promise - removeAccount: ( - account: Partial>, - ) => void - selectAccount: (account: persisted.PersistedAccount) => Promise + initSession: (account: SessionAccount) => Promise + resumeSession: (account?: SessionAccount) => Promise + removeAccount: (account: SessionAccount) => void + selectAccount: (account: SessionAccount) => Promise updateCurrentAccount: ( account: Partial< - Pick + Pick >, ) => void clearCurrentAccount: () => void @@ -46,11 +49,12 @@ export type ApiContext = { const StateContext = React.createContext({ agent: PUBLIC_BSKY_AGENT, - hasSession: false, isInitialLoad: true, isSwitchingAccounts: false, accounts: [], currentAccount: undefined, + hasSession: false, + isSandbox: false, }) const ApiContext = React.createContext({ @@ -94,6 +98,8 @@ function createPersistSessionHandler( logger.DebugContext.session, ) + if (expired) DeviceEventEmitter.emit('session-dropped') + persistSessionCallback({ expired, refreshedAccount, @@ -103,9 +109,8 @@ function createPersistSessionHandler( export function Provider({children}: React.PropsWithChildren<{}>) { const isDirty = React.useRef(false) - const [state, setState] = React.useState({ + const [state, setState] = React.useState({ agent: PUBLIC_BSKY_AGENT, - hasSession: false, isInitialLoad: true, // try to resume the session first isSwitchingAccounts: false, accounts: persisted.get('session').accounts, @@ -113,7 +118,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }) const setStateAndPersist = React.useCallback( - (fn: (prev: StateContext) => StateContext) => { + (fn: (prev: SessionState) => SessionState) => { isDirty.current = true setState(fn) }, @@ -312,9 +317,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { setStateAndPersist(s => { return { ...s, - accounts: s.accounts.filter( - a => !(a.did === account.did || a.handle === account.handle), - ), + accounts: s.accounts.filter(a => a.did !== account.did), } }) }, @@ -431,6 +434,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { () => ({ ...state, hasSession: !!state.currentAccount, + isSandbox: state.currentAccount + ? !IS_PROD(state.currentAccount?.service) + : false, }), [state], ) diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx index c4a3b158..8c94ef2d 100644 --- a/src/view/com/auth/login/ChooseAccountForm.tsx +++ b/src/view/com/auth/login/ChooseAccountForm.tsx @@ -5,7 +5,6 @@ import {useAnalytics} from 'lib/analytics/analytics' import {Text} from '../../util/text/Text' import {UserAvatar} from '../../util/UserAvatar' import {s} from 'lib/styles' -import {AccountData} from 'state/models/session' import {usePalette} from 'lib/hooks/usePalette' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -62,7 +61,7 @@ export const ChooseAccountForm = ({ onSelectAccount, onPressBack, }: { - onSelectAccount: (account?: AccountData) => void + onSelectAccount: (account?: SessionAccount) => void onPressBack: () => void }) => { const {track, screen} = useAnalytics() diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx index de00d6ca..27d08812 100644 --- a/src/view/com/auth/login/Login.tsx +++ b/src/view/com/auth/login/Login.tsx @@ -4,7 +4,6 @@ import {useAnalytics} from 'lib/analytics/analytics' import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' import {useStores, DEFAULT_SERVICE} from 'state/index' import {ServiceDescription} from 'state/models/session' -import {AccountData} from 'state/models/session' import {usePalette} from 'lib/hooks/usePalette' import {logger} from '#/logger' import {ChooseAccountForm} from './ChooseAccountForm' @@ -14,7 +13,7 @@ import {SetNewPasswordForm} from './SetNewPasswordForm' import {PasswordUpdatedForm} from './PasswordUpdatedForm' import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' -import {useSession} from '#/state/session' +import {useSession, SessionAccount} from '#/state/session' enum Forms { Login, @@ -41,7 +40,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { accounts.length ? Forms.ChooseAccount : Forms.Login, ) - const onSelectAccount = (account?: AccountData) => { + const onSelectAccount = (account?: SessionAccount) => { if (account?.service) { setServiceUrl(account.service) } diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index ffae6cbf..6a846f67 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -23,6 +23,7 @@ import useAppState from 'react-native-appstate-hook' import {logger} from '#/logger' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useSession} from '#/state/session' export const FeedPage = observer(function FeedPageImpl({ testID, @@ -38,6 +39,7 @@ export const FeedPage = observer(function FeedPageImpl({ renderEndOfFeed?: () => JSX.Element }) { const store = useStores() + const {isSandbox} = useSession() const pal = usePalette('default') const {_} = useLingui() const {isDesktop} = useWebMediaQueries() @@ -140,7 +142,7 @@ export const FeedPage = observer(function FeedPageImpl({ style={[pal.text, {fontWeight: 'bold'}]} text={ <> - {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} + {isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} {hasNew && ( - }, [isDesktop, pal.view, pal.text, pal.textLight, store, hasNew, _]) + }, [ + isDesktop, + pal.view, + pal.text, + pal.textLight, + store, + hasNew, + _, + isSandbox, + ]) return ( diff --git a/src/view/com/modals/SwitchAccount.tsx b/src/view/com/modals/SwitchAccount.tsx index 05d0da37..3481b861 100644 --- a/src/view/com/modals/SwitchAccount.tsx +++ b/src/view/com/modals/SwitchAccount.tsx @@ -64,7 +64,7 @@ function SwitchAccountCard({account}: {account: SessionAccount}) { ) : ( - + )} ) diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index 8c29ad6a..d79bfe94 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -19,12 +19,14 @@ import {useLingui} from '@lingui/react' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {useSetDrawerOpen} from '#/state/shell/drawer-open' import {useShellLayout} from '#/state/shell/shell-layout' +import {useSession} from '#/state/session' export const FeedsTabBar = observer(function FeedsTabBarImpl( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { const pal = usePalette('default') const store = useStores() + const {isSandbox} = useSession() const {_} = useLingui() const setDrawerOpen = useSetDrawerOpen() const items = useHomeTabs(store.preferences.pinnedFeeds) @@ -59,7 +61,7 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( - {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'} + {isSandbox ? 'SANDBOX' : 'Bluesky'} { - await store.session.login({ + await login({ service: 'http://localhost:3000', identifier: 'alice.test', password: 'hunter2', }) } const onPressSignInBob = async () => { - await store.session.login({ + await login({ service: 'http://localhost:3000', identifier: 'bob.test', password: 'hunter2', @@ -45,7 +47,7 @@ export function TestCtrls() { /> store.session.logout()} + onPress={() => logout()} accessibilityRole="button" style={BTN} /> diff --git a/src/view/com/util/AccountDropdownBtn.tsx b/src/view/com/util/AccountDropdownBtn.tsx index 158ed9b6..96ce678f 100644 --- a/src/view/com/util/AccountDropdownBtn.tsx +++ b/src/view/com/util/AccountDropdownBtn.tsx @@ -8,11 +8,11 @@ import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' import * as Toast from '../../com/util/Toast' -import {useSessionApi} from '#/state/session' +import {useSessionApi, SessionAccount} from '#/state/session' import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' -export function AccountDropdownBtn({handle}: {handle: string}) { +export function AccountDropdownBtn({account}: {account: SessionAccount}) { const pal = usePalette('default') const {removeAccount} = useSessionApi() const {_} = useLingui() @@ -21,7 +21,7 @@ export function AccountDropdownBtn({handle}: {handle: string}) { { label: 'Remove account', onPress: () => { - removeAccount({handle}) + removeAccount(account) Toast.show('Account removed from quick access') }, icon: { diff --git a/src/view/com/util/PostSandboxWarning.tsx b/src/view/com/util/PostSandboxWarning.tsx index 21f5f7b9..b2375c70 100644 --- a/src/view/com/util/PostSandboxWarning.tsx +++ b/src/view/com/util/PostSandboxWarning.tsx @@ -1,13 +1,13 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {Text} from './text/Text' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' +import {useSession} from '#/state/session' export function PostSandboxWarning() { - const store = useStores() + const {isSandbox} = useSession() const pal = usePalette('default') - if (store.session.isSandbox) { + if (isSandbox) { return ( ) : ( - + )} ) diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 99e1d7d9..609348e4 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -47,6 +47,57 @@ import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useSetDrawerOpen} from '#/state/shell' import {useModalControls} from '#/state/modals' +import {useSession, SessionAccount} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' + +export function DrawerProfileCard({ + account, + onPressProfile, +}: { + account: SessionAccount + onPressProfile: () => void +}) { + const {_} = useLingui() + const pal = usePalette('default') + const {data: profile} = useProfileQuery({did: account.did}) + + return ( + + + + {profile?.displayName || account.handle} + + + @{account.handle} + + + + {formatCountShortOnly(profile?.followersCount ?? 0)} + {' '} + {pluralize(profile?.followersCount || 0, 'follower')} ·{' '} + + {formatCountShortOnly(profile?.followsCount ?? 0)} + {' '} + following + + + ) +} export const DrawerContent = observer(function DrawerContentImpl() { const theme = useTheme() @@ -58,6 +109,7 @@ export const DrawerContent = observer(function DrawerContentImpl() { const {track} = useAnalytics() const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = useNavigationTabState() + const {currentAccount} = useSession() const {notifications} = store.me @@ -135,11 +187,11 @@ export const DrawerContent = observer(function DrawerContentImpl() { track('Menu:FeedbackClicked') Linking.openURL( FEEDBACK_FORM_URL({ - email: store.session.currentSession?.email, - handle: store.session.currentSession?.handle, + email: currentAccount?.email, + handle: currentAccount?.handle, }), ) - }, [track, store.session.currentSession]) + }, [track, currentAccount]) const onPressHelp = React.useCallback(() => { track('Menu:HelpClicked') @@ -159,42 +211,12 @@ export const DrawerContent = observer(function DrawerContentImpl() { - - - - {store.me.displayName || store.me.handle} - - - @{store.me.handle} - - - - {formatCountShortOnly(store.me.followersCount ?? 0)} - {' '} - {pluralize(store.me.followersCount || 0, 'follower')} ·{' '} - - {formatCountShortOnly(store.me.followsCount ?? 0)} - {' '} - following - - + )} diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index cb62b4be..98f54c7e 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -17,10 +17,9 @@ import {useModalControls} from '#/state/modals' import {useSession} from '#/state/session' export const DesktopRightNav = observer(function DesktopRightNavImpl() { - const store = useStores() const pal = usePalette('default') const palError = usePalette('error') - const {hasSession, currentAccount} = useSession() + const {isSandbox, hasSession, currentAccount} = useSession() const {isTablet} = useWebMediaQueries() if (isTablet) { @@ -32,7 +31,7 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() { {hasSession && } {hasSession && } - {store.session.isSandbox ? ( + {isSandbox ? ( SANDBOX. Posts and accounts are not permanent.