Pare down session as much as possible

zio/stable
Eric Bailey 2023-11-10 09:59:04 -06:00
parent d0d93168d4
commit 436a14eabb
15 changed files with 126 additions and 532 deletions

View File

@ -43,7 +43,10 @@ export function IS_PROD(url: string) {
// until open federation, "production" is defined as the main server // until open federation, "production" is defined as the main server
// this definition will not work once federation is enabled! // this definition will not work once federation is enabled!
// -prf // -prf
return url.startsWith('https://bsky.social') return (
url.startsWith('https://bsky.social') ||
url.startsWith('https://api.bsky.app')
)
} }
export const PROD_TEAM_HANDLES = [ export const PROD_TEAM_HANDLES = [

View File

@ -63,7 +63,6 @@ export class RootStoreModel {
serialize(): unknown { serialize(): unknown {
return { return {
appInfo: this.appInfo, appInfo: this.appInfo,
session: this.session.serialize(),
me: this.me.serialize(), me: this.me.serialize(),
preferences: this.preferences.serialize(), preferences: this.preferences.serialize(),
} }
@ -80,9 +79,6 @@ export class RootStoreModel {
if (hasProp(v, 'me')) { if (hasProp(v, 'me')) {
this.me.hydrate(v.me) this.me.hydrate(v.me)
} }
if (hasProp(v, 'session')) {
this.session.hydrate(v.session)
}
if (hasProp(v, 'preferences')) { if (hasProp(v, 'preferences')) {
this.preferences.hydrate(v.preferences) this.preferences.hydrate(v.preferences)
} }
@ -92,18 +88,7 @@ export class RootStoreModel {
/** /**
* Called during init to resume any stored session. * Called during init to resume any stored session.
*/ */
async attemptSessionResumption() { 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})
}
}
/** /**
* Called by the session model. Refreshes session-oriented state. * 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() { clearAllSessionState() {
logger.debug('RootStoreModel:clearAllSessionState') logger.debug('RootStoreModel:clearAllSessionState')
this.session.clear()
resetToTab('HomeTab') resetToTab('HomeTab')
this.me.clear() this.me.clear()
} }

View File

@ -1,274 +1,26 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable} from 'mobx'
import { import {
BskyAgent, BskyAgent,
AtpSessionEvent,
AtpSessionData,
ComAtprotoServerDescribeServer as DescribeServer, ComAtprotoServerDescribeServer as DescribeServer,
} from '@atproto/api' } 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 {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 type ServiceDescription = DescribeServer.OutputSchema
export const activeSession = z.object({
service: z.string(),
did: z.string(),
})
export type ActiveSession = z.infer<typeof activeSession>
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<typeof accountData>
interface AdditionalAccountData {
displayName?: string
aviUrl?: string
}
export class SessionModel { export class SessionModel {
// DEBUG
// emergency log facility to help us track down this logout issue
// remove when resolved
// -prf
_log(message: string, details?: Record<string, any>) {
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) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, { makeAutoObservable(this, {
rootStore: false, rootStore: false,
serialize: false,
hydrate: false,
hasSession: false, hasSession: false,
}) })
} }
get currentSession() { get currentSession(): any {
if (!this.data) { return undefined
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 hasSession() { get hasSession() {
return !!this.currentSession && !!this.rootStore.agent.session return false
}
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,
}
}
} }
/** /**
@ -280,193 +32,8 @@ export class SessionModel {
return res.data return res.data
} }
/**
* Attempt to resume a session that we still have access tokens for.
*/
async resumeSession(account: AccountData): Promise<boolean> {
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. * Reloads the session from the server. Useful when account details change, like the handle.
*/ */
async reloadFromServer() { 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<AccountData>) {
this.accounts = this.accounts.map(acct =>
acct.did === this.data?.did ? {...acct, ...changes} : acct,
)
}
} }

View File

@ -1,20 +1,25 @@
import React from 'react' import React from 'react'
import {DeviceEventEmitter} from 'react-native'
import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api' import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api'
import {networkRetry} from '#/lib/async/retry' import {networkRetry} from '#/lib/async/retry'
import {logger} from '#/logger' import {logger} from '#/logger'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {PUBLIC_BSKY_AGENT} from '#/state/queries' import {PUBLIC_BSKY_AGENT} from '#/state/queries'
import {IS_PROD} from '#/lib/constants'
export type SessionAccount = persisted.PersistedAccount export type SessionAccount = persisted.PersistedAccount
export type StateContext = { export type SessionState = {
agent: BskyAgent agent: BskyAgent
isInitialLoad: boolean isInitialLoad: boolean
isSwitchingAccounts: boolean isSwitchingAccounts: boolean
accounts: persisted.PersistedAccount[] accounts: persisted.PersistedAccount[]
currentAccount: persisted.PersistedAccount | undefined currentAccount: persisted.PersistedAccount | undefined
}
export type StateContext = SessionState & {
hasSession: boolean hasSession: boolean
isSandbox: boolean
} }
export type ApiContext = { export type ApiContext = {
createAccount: (props: { createAccount: (props: {
@ -30,15 +35,13 @@ export type ApiContext = {
password: string password: string
}) => Promise<void> }) => Promise<void>
logout: () => Promise<void> logout: () => Promise<void>
initSession: (account: persisted.PersistedAccount) => Promise<void> initSession: (account: SessionAccount) => Promise<void>
resumeSession: (account?: persisted.PersistedAccount) => Promise<void> resumeSession: (account?: SessionAccount) => Promise<void>
removeAccount: ( removeAccount: (account: SessionAccount) => void
account: Partial<Pick<persisted.PersistedAccount, 'handle' | 'did'>>, selectAccount: (account: SessionAccount) => Promise<void>
) => void
selectAccount: (account: persisted.PersistedAccount) => Promise<void>
updateCurrentAccount: ( updateCurrentAccount: (
account: Partial< account: Partial<
Pick<persisted.PersistedAccount, 'handle' | 'email' | 'emailConfirmed'> Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'>
>, >,
) => void ) => void
clearCurrentAccount: () => void clearCurrentAccount: () => void
@ -46,11 +49,12 @@ export type ApiContext = {
const StateContext = React.createContext<StateContext>({ const StateContext = React.createContext<StateContext>({
agent: PUBLIC_BSKY_AGENT, agent: PUBLIC_BSKY_AGENT,
hasSession: false,
isInitialLoad: true, isInitialLoad: true,
isSwitchingAccounts: false, isSwitchingAccounts: false,
accounts: [], accounts: [],
currentAccount: undefined, currentAccount: undefined,
hasSession: false,
isSandbox: false,
}) })
const ApiContext = React.createContext<ApiContext>({ const ApiContext = React.createContext<ApiContext>({
@ -94,6 +98,8 @@ function createPersistSessionHandler(
logger.DebugContext.session, logger.DebugContext.session,
) )
if (expired) DeviceEventEmitter.emit('session-dropped')
persistSessionCallback({ persistSessionCallback({
expired, expired,
refreshedAccount, refreshedAccount,
@ -103,9 +109,8 @@ function createPersistSessionHandler(
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
const isDirty = React.useRef(false) const isDirty = React.useRef(false)
const [state, setState] = React.useState<StateContext>({ const [state, setState] = React.useState<SessionState>({
agent: PUBLIC_BSKY_AGENT, agent: PUBLIC_BSKY_AGENT,
hasSession: false,
isInitialLoad: true, // try to resume the session first isInitialLoad: true, // try to resume the session first
isSwitchingAccounts: false, isSwitchingAccounts: false,
accounts: persisted.get('session').accounts, accounts: persisted.get('session').accounts,
@ -113,7 +118,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}) })
const setStateAndPersist = React.useCallback( const setStateAndPersist = React.useCallback(
(fn: (prev: StateContext) => StateContext) => { (fn: (prev: SessionState) => SessionState) => {
isDirty.current = true isDirty.current = true
setState(fn) setState(fn)
}, },
@ -312,9 +317,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
setStateAndPersist(s => { setStateAndPersist(s => {
return { return {
...s, ...s,
accounts: s.accounts.filter( accounts: s.accounts.filter(a => a.did !== account.did),
a => !(a.did === account.did || a.handle === account.handle),
),
} }
}) })
}, },
@ -431,6 +434,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
() => ({ () => ({
...state, ...state,
hasSession: !!state.currentAccount, hasSession: !!state.currentAccount,
isSandbox: state.currentAccount
? !IS_PROD(state.currentAccount?.service)
: false,
}), }),
[state], [state],
) )

View File

@ -5,7 +5,6 @@ import {useAnalytics} from 'lib/analytics/analytics'
import {Text} from '../../util/text/Text' import {Text} from '../../util/text/Text'
import {UserAvatar} from '../../util/UserAvatar' import {UserAvatar} from '../../util/UserAvatar'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {AccountData} from 'state/models/session'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@ -62,7 +61,7 @@ export const ChooseAccountForm = ({
onSelectAccount, onSelectAccount,
onPressBack, onPressBack,
}: { }: {
onSelectAccount: (account?: AccountData) => void onSelectAccount: (account?: SessionAccount) => void
onPressBack: () => void onPressBack: () => void
}) => { }) => {
const {track, screen} = useAnalytics() const {track, screen} = useAnalytics()

View File

@ -4,7 +4,6 @@ import {useAnalytics} from 'lib/analytics/analytics'
import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
import {useStores, DEFAULT_SERVICE} from 'state/index' import {useStores, DEFAULT_SERVICE} from 'state/index'
import {ServiceDescription} from 'state/models/session' import {ServiceDescription} from 'state/models/session'
import {AccountData} from 'state/models/session'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {logger} from '#/logger' import {logger} from '#/logger'
import {ChooseAccountForm} from './ChooseAccountForm' import {ChooseAccountForm} from './ChooseAccountForm'
@ -14,7 +13,7 @@ import {SetNewPasswordForm} from './SetNewPasswordForm'
import {PasswordUpdatedForm} from './PasswordUpdatedForm' import {PasswordUpdatedForm} from './PasswordUpdatedForm'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useSession} from '#/state/session' import {useSession, SessionAccount} from '#/state/session'
enum Forms { enum Forms {
Login, Login,
@ -41,7 +40,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
accounts.length ? Forms.ChooseAccount : Forms.Login, accounts.length ? Forms.ChooseAccount : Forms.Login,
) )
const onSelectAccount = (account?: AccountData) => { const onSelectAccount = (account?: SessionAccount) => {
if (account?.service) { if (account?.service) {
setServiceUrl(account.service) setServiceUrl(account.service)
} }

View File

@ -23,6 +23,7 @@ import useAppState from 'react-native-appstate-hook'
import {logger} from '#/logger' import {logger} from '#/logger'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session'
export const FeedPage = observer(function FeedPageImpl({ export const FeedPage = observer(function FeedPageImpl({
testID, testID,
@ -38,6 +39,7 @@ export const FeedPage = observer(function FeedPageImpl({
renderEndOfFeed?: () => JSX.Element renderEndOfFeed?: () => JSX.Element
}) { }) {
const store = useStores() const store = useStores()
const {isSandbox} = useSession()
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const {isDesktop} = useWebMediaQueries() const {isDesktop} = useWebMediaQueries()
@ -140,7 +142,7 @@ export const FeedPage = observer(function FeedPageImpl({
style={[pal.text, {fontWeight: 'bold'}]} style={[pal.text, {fontWeight: 'bold'}]}
text={ text={
<> <>
{store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} {isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
{hasNew && ( {hasNew && (
<View <View
style={{ style={{
@ -173,7 +175,16 @@ export const FeedPage = observer(function FeedPageImpl({
) )
} }
return <></> return <></>
}, [isDesktop, pal.view, pal.text, pal.textLight, store, hasNew, _]) }, [
isDesktop,
pal.view,
pal.text,
pal.textLight,
store,
hasNew,
_,
isSandbox,
])
return ( return (
<View testID={testID} style={s.h100pct}> <View testID={testID} style={s.h100pct}>

View File

@ -64,7 +64,7 @@ function SwitchAccountCard({account}: {account: SessionAccount}) {
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
<AccountDropdownBtn handle={account.handle} /> <AccountDropdownBtn account={account} />
)} )}
</View> </View>
) )

View File

@ -19,12 +19,14 @@ import {useLingui} from '@lingui/react'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {useSetDrawerOpen} from '#/state/shell/drawer-open' import {useSetDrawerOpen} from '#/state/shell/drawer-open'
import {useShellLayout} from '#/state/shell/shell-layout' import {useShellLayout} from '#/state/shell/shell-layout'
import {useSession} from '#/state/session'
export const FeedsTabBar = observer(function FeedsTabBarImpl( export const FeedsTabBar = observer(function FeedsTabBarImpl(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) { ) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const {isSandbox} = useSession()
const {_} = useLingui() const {_} = useLingui()
const setDrawerOpen = useSetDrawerOpen() const setDrawerOpen = useSetDrawerOpen()
const items = useHomeTabs(store.preferences.pinnedFeeds) const items = useHomeTabs(store.preferences.pinnedFeeds)
@ -59,7 +61,7 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<Text style={[brandBlue, s.bold, styles.title]}> <Text style={[brandBlue, s.bold, styles.title]}>
{store.session.isSandbox ? 'SANDBOX' : 'Bluesky'} {isSandbox ? 'SANDBOX' : 'Bluesky'}
</Text> </Text>
<View style={[pal.view]}> <View style={[pal.view]}>
<Link <Link

View File

@ -3,6 +3,7 @@ import {Pressable, View} from 'react-native'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {navigate} from '../../../Navigation' import {navigate} from '../../../Navigation'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useSessionApi} from '#/state/session'
/** /**
* This utility component is only included in the test simulator * This utility component is only included in the test simulator
@ -14,16 +15,17 @@ const BTN = {height: 1, width: 1, backgroundColor: 'red'}
export function TestCtrls() { export function TestCtrls() {
const store = useStores() const store = useStores()
const {logout, login} = useSessionApi()
const {openModal} = useModalControls() const {openModal} = useModalControls()
const onPressSignInAlice = async () => { const onPressSignInAlice = async () => {
await store.session.login({ await login({
service: 'http://localhost:3000', service: 'http://localhost:3000',
identifier: 'alice.test', identifier: 'alice.test',
password: 'hunter2', password: 'hunter2',
}) })
} }
const onPressSignInBob = async () => { const onPressSignInBob = async () => {
await store.session.login({ await login({
service: 'http://localhost:3000', service: 'http://localhost:3000',
identifier: 'bob.test', identifier: 'bob.test',
password: 'hunter2', password: 'hunter2',
@ -45,7 +47,7 @@ export function TestCtrls() {
/> />
<Pressable <Pressable
testID="e2eSignOut" testID="e2eSignOut"
onPress={() => store.session.logout()} onPress={() => logout()}
accessibilityRole="button" accessibilityRole="button"
style={BTN} style={BTN}
/> />

View File

@ -8,11 +8,11 @@ import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
import * as Toast from '../../com/util/Toast' import * as Toast from '../../com/util/Toast'
import {useSessionApi} from '#/state/session' import {useSessionApi, SessionAccount} from '#/state/session'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
export function AccountDropdownBtn({handle}: {handle: string}) { export function AccountDropdownBtn({account}: {account: SessionAccount}) {
const pal = usePalette('default') const pal = usePalette('default')
const {removeAccount} = useSessionApi() const {removeAccount} = useSessionApi()
const {_} = useLingui() const {_} = useLingui()
@ -21,7 +21,7 @@ export function AccountDropdownBtn({handle}: {handle: string}) {
{ {
label: 'Remove account', label: 'Remove account',
onPress: () => { onPress: () => {
removeAccount({handle}) removeAccount(account)
Toast.show('Account removed from quick access') Toast.show('Account removed from quick access')
}, },
icon: { icon: {

View File

@ -1,13 +1,13 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {Text} from './text/Text' import {Text} from './text/Text'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useSession} from '#/state/session'
export function PostSandboxWarning() { export function PostSandboxWarning() {
const store = useStores() const {isSandbox} = useSession()
const pal = usePalette('default') const pal = usePalette('default')
if (store.session.isSandbox) { if (isSandbox) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text <Text

View File

@ -102,7 +102,7 @@ function SettingsAccountCard({account}: {account: SessionAccount}) {
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
<AccountDropdownBtn handle={account.handle} /> <AccountDropdownBtn account={account} />
)} )}
</View> </View>
) )

View File

@ -47,6 +47,57 @@ import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useSetDrawerOpen} from '#/state/shell' import {useSetDrawerOpen} from '#/state/shell'
import {useModalControls} from '#/state/modals' 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 (
<TouchableOpacity
testID="profileCardButton"
accessibilityLabel={_(msg`Profile`)}
accessibilityHint="Navigates to your profile"
onPress={onPressProfile}>
<UserAvatar
size={80}
avatar={profile?.avatar}
// See https://github.com/bluesky-social/social-app/pull/1801:
usePlainRNImage={true}
/>
<Text
type="title-lg"
style={[pal.text, s.bold, styles.profileCardDisplayName]}
numberOfLines={1}>
{profile?.displayName || account.handle}
</Text>
<Text
type="2xl"
style={[pal.textLight, styles.profileCardHandle]}
numberOfLines={1}>
@{account.handle}
</Text>
<Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}>
<Text type="xl-medium" style={pal.text}>
{formatCountShortOnly(profile?.followersCount ?? 0)}
</Text>{' '}
{pluralize(profile?.followersCount || 0, 'follower')} &middot;{' '}
<Text type="xl-medium" style={pal.text}>
{formatCountShortOnly(profile?.followsCount ?? 0)}
</Text>{' '}
following
</Text>
</TouchableOpacity>
)
}
export const DrawerContent = observer(function DrawerContentImpl() { export const DrawerContent = observer(function DrawerContentImpl() {
const theme = useTheme() const theme = useTheme()
@ -58,6 +109,7 @@ export const DrawerContent = observer(function DrawerContentImpl() {
const {track} = useAnalytics() const {track} = useAnalytics()
const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
useNavigationTabState() useNavigationTabState()
const {currentAccount} = useSession()
const {notifications} = store.me const {notifications} = store.me
@ -135,11 +187,11 @@ export const DrawerContent = observer(function DrawerContentImpl() {
track('Menu:FeedbackClicked') track('Menu:FeedbackClicked')
Linking.openURL( Linking.openURL(
FEEDBACK_FORM_URL({ FEEDBACK_FORM_URL({
email: store.session.currentSession?.email, email: currentAccount?.email,
handle: store.session.currentSession?.handle, handle: currentAccount?.handle,
}), }),
) )
}, [track, store.session.currentSession]) }, [track, currentAccount])
const onPressHelp = React.useCallback(() => { const onPressHelp = React.useCallback(() => {
track('Menu:HelpClicked') track('Menu:HelpClicked')
@ -159,42 +211,12 @@ export const DrawerContent = observer(function DrawerContentImpl() {
<SafeAreaView style={s.flex1}> <SafeAreaView style={s.flex1}>
<ScrollView style={styles.main}> <ScrollView style={styles.main}>
<View style={{}}> <View style={{}}>
<TouchableOpacity {currentAccount && (
testID="profileCardButton" <DrawerProfileCard
accessibilityLabel={_(msg`Profile`)} account={currentAccount}
accessibilityHint="Navigates to your profile" onPressProfile={onPressProfile}
onPress={onPressProfile}>
<UserAvatar
size={80}
avatar={store.me.avatar}
// See https://github.com/bluesky-social/social-app/pull/1801:
usePlainRNImage={true}
/> />
<Text )}
type="title-lg"
style={[pal.text, s.bold, styles.profileCardDisplayName]}
numberOfLines={1}>
{store.me.displayName || store.me.handle}
</Text>
<Text
type="2xl"
style={[pal.textLight, styles.profileCardHandle]}
numberOfLines={1}>
@{store.me.handle}
</Text>
<Text
type="xl"
style={[pal.textLight, styles.profileCardFollowers]}>
<Text type="xl-medium" style={pal.text}>
{formatCountShortOnly(store.me.followersCount ?? 0)}
</Text>{' '}
{pluralize(store.me.followersCount || 0, 'follower')} &middot;{' '}
<Text type="xl-medium" style={pal.text}>
{formatCountShortOnly(store.me.followsCount ?? 0)}
</Text>{' '}
following
</Text>
</TouchableOpacity>
</View> </View>
<InviteCodes style={{paddingLeft: 0}} /> <InviteCodes style={{paddingLeft: 0}} />

View File

@ -17,10 +17,9 @@ import {useModalControls} from '#/state/modals'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
export const DesktopRightNav = observer(function DesktopRightNavImpl() { export const DesktopRightNav = observer(function DesktopRightNavImpl() {
const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const palError = usePalette('error') const palError = usePalette('error')
const {hasSession, currentAccount} = useSession() const {isSandbox, hasSession, currentAccount} = useSession()
const {isTablet} = useWebMediaQueries() const {isTablet} = useWebMediaQueries()
if (isTablet) { if (isTablet) {
@ -32,7 +31,7 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() {
{hasSession && <DesktopSearch />} {hasSession && <DesktopSearch />}
{hasSession && <DesktopFeeds />} {hasSession && <DesktopFeeds />}
<View style={styles.message}> <View style={styles.message}>
{store.session.isSandbox ? ( {isSandbox ? (
<View style={[palError.view, styles.messageLine, s.p10]}> <View style={[palError.view, styles.messageLine, s.p10]}>
<Text type="md" style={[palError.text, s.bold]}> <Text type="md" style={[palError.text, s.bold]}>
SANDBOX. Posts and accounts are not permanent. SANDBOX. Posts and accounts are not permanent.