Refactor session management to use a new "Agent" API (#165)

* Add the atp-agent implementation (temporarily in this repo)

* Rewrite all session & API management to use the new atp-agent

* Update tests for the atp-agent refactor

* Refactor management of session-related state. Includes:
- More careful management of when state is cleared or fetched
- Debug logging to help trace future issues
- Clearer APIs overall

* Bubble session-expiration events to the user and display a toast to explain

* Switch to the new @atproto/api@0.1.0
This commit is contained in:
Paul Frazee
2023-02-07 16:04:08 -06:00
committed by GitHub
parent 0e160298c8
commit 759ec8011b
23 changed files with 464 additions and 471 deletions
+2 -2
View File
@@ -648,8 +648,8 @@ export const mockedRootStore = {
resolveName: jest.fn(), resolveName: jest.fn(),
serialize: jest.fn(), serialize: jest.fn(),
hydrate: jest.fn(), hydrate: jest.fn(),
fetchStateUpdate: jest.fn(), updateSessionState: jest.fn(),
clearAll: jest.fn(), clearAllSessionState: jest.fn(),
session: mockedSessionStore, session: mockedSessionStore,
nav: mockedNavigationStore, nav: mockedNavigationStore,
shell: mockedShellStore, shell: mockedShellStore,
+1 -1
View File
@@ -4,7 +4,7 @@ import {cleanup, fireEvent, render, waitFor} from '../jest/test-utils'
import {createServer, TestPDS} from '../jest/test-pds' import {createServer, TestPDS} from '../jest/test-pds'
import {RootStoreModel, setupState} from '../src/state' import {RootStoreModel, setupState} from '../src/state'
const WAIT_OPTS = {timeout: 5e3} const WAIT_OPTS = {timeout: 10e3}
describe('Account flows', () => { describe('Account flows', () => {
let pds: TestPDS | undefined let pds: TestPDS | undefined
@@ -2,7 +2,7 @@ import {RootStoreModel} from '../../../src/state/models/root-store'
import {LinkMetasViewModel} from '../../../src/state/models/link-metas-view' import {LinkMetasViewModel} from '../../../src/state/models/link-metas-view'
import * as LinkMetaLib from '../../../src/lib/link-meta' import * as LinkMetaLib from '../../../src/lib/link-meta'
import {LikelyType} from './../../../src/lib/link-meta' import {LikelyType} from './../../../src/lib/link-meta'
import {sessionClient, SessionServiceClient} from '@atproto/api' import AtpAgent from '@atproto/api'
import {DEFAULT_SERVICE} from '../../../src/state' import {DEFAULT_SERVICE} from '../../../src/state'
describe('LinkMetasViewModel', () => { describe('LinkMetasViewModel', () => {
@@ -17,8 +17,7 @@ describe('LinkMetasViewModel', () => {
} }
beforeEach(() => { beforeEach(() => {
const api = sessionClient.service(DEFAULT_SERVICE) as SessionServiceClient rootStore = new RootStoreModel(new AtpAgent({service: DEFAULT_SERVICE}))
rootStore = new RootStoreModel(api)
viewModel = new LinkMetasViewModel(rootStore) viewModel = new LinkMetasViewModel(rootStore)
}) })
+90 -109
View File
@@ -1,106 +1,110 @@
import {RootStoreModel} from '../../../src/state/models/root-store' import {createServer, TestPDS} from '../../../jest/test-pds'
import {MeModel} from '../../../src/state/models/me' import {RootStoreModel, setupState} from '../../../src/state'
import {NotificationsViewModel} from './../../../src/state/models/notifications-view' import {NotificationsViewModel} from '../../../src/state/models/notifications-view'
import {sessionClient, SessionServiceClient} from '@atproto/api'
import {DEFAULT_SERVICE} from './../../../src/state/index'
describe('MeModel', () => { describe('MeModel', () => {
let pds: TestPDS | undefined
let rootStore: RootStoreModel let rootStore: RootStoreModel
let meModel: MeModel beforeAll(async () => {
jest.useFakeTimers()
beforeEach(() => { pds = await createServer()
const api = sessionClient.service(DEFAULT_SERVICE) as SessionServiceClient rootStore = await setupState(pds.pdsUrl)
rootStore = new RootStoreModel(api)
meModel = new MeModel(rootStore)
}) })
afterAll(() => { afterAll(async () => {
jest.clearAllMocks() jest.clearAllMocks()
await pds?.close()
}) })
it('should clear() correctly', () => { it('should clear() correctly', () => {
meModel.did = '123' rootStore.me.did = '123'
meModel.handle = 'handle' rootStore.me.handle = 'handle'
meModel.displayName = 'John Doe' rootStore.me.displayName = 'John Doe'
meModel.description = 'description' rootStore.me.description = 'description'
meModel.avatar = 'avatar' rootStore.me.avatar = 'avatar'
meModel.notificationCount = 1 rootStore.me.notificationCount = 1
meModel.clear() rootStore.me.clear()
expect(meModel.did).toEqual('') expect(rootStore.me.did).toEqual('')
expect(meModel.handle).toEqual('') expect(rootStore.me.handle).toEqual('')
expect(meModel.displayName).toEqual('') expect(rootStore.me.displayName).toEqual('')
expect(meModel.description).toEqual('') expect(rootStore.me.description).toEqual('')
expect(meModel.avatar).toEqual('') expect(rootStore.me.avatar).toEqual('')
expect(meModel.notificationCount).toEqual(0) expect(rootStore.me.notificationCount).toEqual(0)
}) })
it('should hydrate() successfully with valid properties', () => { it('should hydrate() successfully with valid properties', () => {
meModel.hydrate({ rootStore.me.clear()
rootStore.me.hydrate({
did: '123', did: '123',
handle: 'handle', handle: 'handle',
displayName: 'John Doe', displayName: 'John Doe',
description: 'description', description: 'description',
avatar: 'avatar', avatar: 'avatar',
}) })
expect(meModel.did).toEqual('123') expect(rootStore.me.did).toEqual('123')
expect(meModel.handle).toEqual('handle') expect(rootStore.me.handle).toEqual('handle')
expect(meModel.displayName).toEqual('John Doe') expect(rootStore.me.displayName).toEqual('John Doe')
expect(meModel.description).toEqual('description') expect(rootStore.me.description).toEqual('description')
expect(meModel.avatar).toEqual('avatar') expect(rootStore.me.avatar).toEqual('avatar')
}) })
it('should not hydrate() with invalid properties', () => { it('should not hydrate() with invalid properties', () => {
meModel.hydrate({ rootStore.me.clear()
rootStore.me.hydrate({
did: '', did: '',
handle: 'handle', handle: 'handle',
displayName: 'John Doe', displayName: 'John Doe',
description: 'description', description: 'description',
avatar: 'avatar', avatar: 'avatar',
}) })
expect(meModel.did).toEqual('') expect(rootStore.me.did).toEqual('')
expect(meModel.handle).toEqual('') expect(rootStore.me.handle).toEqual('')
expect(meModel.displayName).toEqual('') expect(rootStore.me.displayName).toEqual('')
expect(meModel.description).toEqual('') expect(rootStore.me.description).toEqual('')
expect(meModel.avatar).toEqual('') expect(rootStore.me.avatar).toEqual('')
meModel.hydrate({ rootStore.me.hydrate({
did: '123', did: '123',
displayName: 'John Doe', displayName: 'John Doe',
description: 'description', description: 'description',
avatar: 'avatar', avatar: 'avatar',
}) })
expect(meModel.did).toEqual('') expect(rootStore.me.did).toEqual('')
expect(meModel.handle).toEqual('') expect(rootStore.me.handle).toEqual('')
expect(meModel.displayName).toEqual('') expect(rootStore.me.displayName).toEqual('')
expect(meModel.description).toEqual('') expect(rootStore.me.description).toEqual('')
expect(meModel.avatar).toEqual('') expect(rootStore.me.avatar).toEqual('')
})
it('should serialize() key information', () => {
rootStore.me.did = '123'
rootStore.me.handle = 'handle'
rootStore.me.displayName = 'John Doe'
rootStore.me.description = 'description'
rootStore.me.avatar = 'avatar'
expect(rootStore.me.serialize()).toEqual({
did: '123',
handle: 'handle',
displayName: 'John Doe',
description: 'description',
avatar: 'avatar',
})
}) })
it('should load() successfully', async () => { it('should load() successfully', async () => {
jest await rootStore.session.login({
.spyOn(rootStore.api.app.bsky.actor, 'getProfile') service: pds?.pdsUrl || '',
.mockImplementationOnce((): Promise<any> => { identifier: 'alice.test',
return Promise.resolve({ password: 'hunter2',
data: {
displayName: 'John Doe',
description: 'description',
avatar: 'avatar',
},
}) })
})
rootStore.session.data = { await rootStore.me.load()
did: '123', expect(typeof rootStore.me.did).toEqual('string')
handle: 'handle', expect(rootStore.me.handle).toEqual('alice.test')
service: 'test service', expect(rootStore.me.displayName).toEqual('Alice')
accessJwt: 'test token', expect(rootStore.me.description).toEqual('Test user 1')
refreshJwt: 'test token', expect(rootStore.me.avatar).toEqual('')
}
await meModel.load()
expect(meModel.did).toEqual('123')
expect(meModel.handle).toEqual('handle')
expect(meModel.displayName).toEqual('John Doe')
expect(meModel.description).toEqual('description')
expect(meModel.avatar).toEqual('avatar')
}) })
it('should load() successfully without profile data', async () => { it('should load() successfully without profile data', async () => {
@@ -111,55 +115,32 @@ describe('MeModel', () => {
data: null, data: null,
}) })
}) })
rootStore.session.data = { await rootStore.me.load()
did: '123', expect(typeof rootStore.me.did).toEqual('string')
handle: 'handle', expect(rootStore.me.handle).toEqual('alice.test')
service: 'test service', expect(rootStore.me.displayName).toEqual('')
accessJwt: 'test token', expect(rootStore.me.description).toEqual('')
refreshJwt: 'test token', expect(rootStore.me.avatar).toEqual('')
}
await meModel.load()
expect(meModel.did).toEqual('123')
expect(meModel.handle).toEqual('handle')
expect(meModel.displayName).toEqual('')
expect(meModel.description).toEqual('')
expect(meModel.avatar).toEqual('')
}) })
it('should load() to nothing when no session', async () => { it('should load() to nothing when no session', async () => {
rootStore.session.data = null await rootStore.session.logout()
await meModel.load() await rootStore.me.load()
expect(meModel.did).toEqual('') expect(rootStore.me.did).toEqual('')
expect(meModel.handle).toEqual('') expect(rootStore.me.handle).toEqual('')
expect(meModel.displayName).toEqual('') expect(rootStore.me.displayName).toEqual('')
expect(meModel.description).toEqual('') expect(rootStore.me.description).toEqual('')
expect(meModel.avatar).toEqual('') expect(rootStore.me.avatar).toEqual('')
expect(meModel.notificationCount).toEqual(0) expect(rootStore.me.notificationCount).toEqual(0)
})
it('should serialize() key information', () => {
meModel.did = '123'
meModel.handle = 'handle'
meModel.displayName = 'John Doe'
meModel.description = 'description'
meModel.avatar = 'avatar'
expect(meModel.serialize()).toEqual({
did: '123',
handle: 'handle',
displayName: 'John Doe',
description: 'description',
avatar: 'avatar',
})
}) })
it('should clearNotificationCount() successfully', () => { it('should clearNotificationCount() successfully', () => {
meModel.clearNotificationCount() rootStore.me.clearNotificationCount()
expect(meModel.notificationCount).toBe(0) expect(rootStore.me.notificationCount).toBe(0)
}) })
it('should update notifs count with fetchStateUpdate()', async () => { it('should update notifs count with fetchStateUpdate()', async () => {
meModel.notifications = { rootStore.me.notifications = {
refresh: jest.fn().mockResolvedValue({}), refresh: jest.fn().mockResolvedValue({}),
} as unknown as NotificationsViewModel } as unknown as NotificationsViewModel
@@ -173,8 +154,8 @@ describe('MeModel', () => {
}) })
}) })
await meModel.fetchNotifications() await rootStore.me.fetchNotifications()
expect(meModel.notificationCount).toBe(1) expect(rootStore.me.notificationCount).toBe(1)
expect(meModel.notifications.refresh).toHaveBeenCalled() expect(rootStore.me.notifications.refresh).toHaveBeenCalled()
}) })
}) })
+1 -1
View File
@@ -17,7 +17,7 @@ describe('rootStore', () => {
}) })
it('should call the clearAll() resets state correctly', () => { it('should call the clearAll() resets state correctly', () => {
rootStore.clearAll() rootStore.clearAllSessionState()
expect(rootStore.session.data).toEqual(null) expect(rootStore.session.data).toEqual(null)
expect(rootStore.nav.tabs).toEqual([ expect(rootStore.nav.tabs).toEqual([
+15 -15
View File
@@ -8,7 +8,7 @@ import PDSServer, {
ServerConfig as PDSServerConfig, ServerConfig as PDSServerConfig,
} from '@atproto/pds' } from '@atproto/pds'
import * as plc from '@atproto/plc' import * as plc from '@atproto/plc'
import AtpApi, {ServiceClient} from '@atproto/api' import AtpAgent from '@atproto/api'
export interface TestUser { export interface TestUser {
email: string email: string
@@ -16,7 +16,7 @@ export interface TestUser {
declarationCid: string declarationCid: string
handle: string handle: string
password: string password: string
api: ServiceClient agent: AtpAgent
} }
export interface TestUsers { export interface TestUsers {
@@ -112,11 +112,11 @@ export async function createServer(): Promise<TestPDS> {
async function genMockData(pdsUrl: string): Promise<TestUsers> { async function genMockData(pdsUrl: string): Promise<TestUsers> {
const date = dateGen() const date = dateGen()
const clients = { const agents = {
loggedout: AtpApi.service(pdsUrl), loggedout: new AtpAgent({service: pdsUrl}),
alice: AtpApi.service(pdsUrl), alice: new AtpAgent({service: pdsUrl}),
bob: AtpApi.service(pdsUrl), bob: new AtpAgent({service: pdsUrl}),
carla: AtpApi.service(pdsUrl), carla: new AtpAgent({service: pdsUrl}),
} }
const users: TestUser[] = [ const users: TestUser[] = [
{ {
@@ -125,7 +125,7 @@ async function genMockData(pdsUrl: string): Promise<TestUsers> {
declarationCid: '', declarationCid: '',
handle: 'alice.test', handle: 'alice.test',
password: 'hunter2', password: 'hunter2',
api: clients.alice, agent: agents.alice,
}, },
{ {
email: 'bob@test.com', email: 'bob@test.com',
@@ -133,7 +133,7 @@ async function genMockData(pdsUrl: string): Promise<TestUsers> {
declarationCid: '', declarationCid: '',
handle: 'bob.test', handle: 'bob.test',
password: 'hunter2', password: 'hunter2',
api: clients.bob, agent: agents.bob,
}, },
{ {
email: 'carla@test.com', email: 'carla@test.com',
@@ -141,7 +141,7 @@ async function genMockData(pdsUrl: string): Promise<TestUsers> {
declarationCid: '', declarationCid: '',
handle: 'carla.test', handle: 'carla.test',
password: 'hunter2', password: 'hunter2',
api: clients.carla, agent: agents.carla,
}, },
] ]
const alice = users[0] const alice = users[0]
@@ -150,18 +150,18 @@ async function genMockData(pdsUrl: string): Promise<TestUsers> {
let _i = 1 let _i = 1
for (const user of users) { for (const user of users) {
const res = await clients.loggedout.com.atproto.account.create({ const res = await agents.loggedout.api.com.atproto.account.create({
email: user.email, email: user.email,
handle: user.handle, handle: user.handle,
password: user.password, password: user.password,
}) })
user.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`) user.agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
const {data: profile} = await user.api.app.bsky.actor.getProfile({ const {data: profile} = await user.agent.api.app.bsky.actor.getProfile({
actor: user.handle, actor: user.handle,
}) })
user.did = res.data.did user.did = res.data.did
user.declarationCid = profile.declaration.cid user.declarationCid = profile.declaration.cid
await user.api.app.bsky.actor.profile.create( await user.agent.api.app.bsky.actor.profile.create(
{did: user.did}, {did: user.did},
{ {
displayName: ucfirst(user.handle).slice(0, -5), displayName: ucfirst(user.handle).slice(0, -5),
@@ -172,7 +172,7 @@ async function genMockData(pdsUrl: string): Promise<TestUsers> {
// everybody follows everybody // everybody follows everybody
const follow = async (author: TestUser, subject: TestUser) => { const follow = async (author: TestUser, subject: TestUser) => {
await author.api.app.bsky.graph.follow.create( await author.agent.api.app.bsky.graph.follow.create(
{did: author.did}, {did: author.did},
{ {
subject: { subject: {
+4 -3
View File
@@ -16,9 +16,9 @@
"e2e": "detox test --configuration ios.sim.debug --take-screenshots all" "e2e": "detox test --configuration ios.sim.debug --take-screenshots all"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.0.8", "@atproto/api": "^0.1.0",
"@atproto/lexicon": "^0.0.4", "@atproto/lexicon": "^0.0.4",
"@atproto/xrpc": "^0.0.3", "@atproto/xrpc": "^0.0.4",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-regular-svg-icons": "^6.1.1",
@@ -43,6 +43,7 @@
"lru_map": "^0.4.1", "lru_map": "^0.4.1",
"mobx": "^6.6.1", "mobx": "^6.6.1",
"mobx-react-lite": "^3.4.0", "mobx-react-lite": "^3.4.0",
"normalize-url": "^8.0.0",
"react": "18.2.0", "react": "18.2.0",
"react-circular-progressbar": "^2.1.0", "react-circular-progressbar": "^2.1.0",
"react-dom": "17.0.2", "react-dom": "17.0.2",
@@ -128,7 +129,7 @@
"node" "node"
], ],
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"node_modules/(?!(jest-)?react-native|react-clone-referenced-element|@react-native-community|rollbar-react-native|@fortawesome|@react-native|@react-navigation)" "node_modules/(?!(jest-)?react-native|react-clone-referenced-element|@react-native-community|rollbar-react-native|@fortawesome|@react-native|@react-navigation|normalize-url)"
], ],
"modulePathIgnorePatterns": [ "modulePathIgnorePatterns": [
"__tests__/.*/__mocks__", "__tests__/.*/__mocks__",
+4
View File
@@ -14,6 +14,7 @@ import {MobileShell} from './view/shell/mobile'
import {s} from './view/lib/styles' import {s} from './view/lib/styles'
import notifee, {EventType} from '@notifee/react-native' import notifee, {EventType} from '@notifee/react-native'
import {segmentClient} from './lib/segmentClient' import {segmentClient} from './lib/segmentClient'
import * as Toast from './view/com/util/Toast'
const App = observer(() => { const App = observer(() => {
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>( const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
@@ -36,6 +37,9 @@ const App = observer(() => {
Linking.addEventListener('url', ({url}) => { Linking.addEventListener('url', ({url}) => {
store.nav.handleLink(url) store.nav.handleLink(url)
}) })
store.onSessionDropped(() => {
Toast.show('Sorry! Your session expired. Please log in again.')
})
notifee.onForegroundEvent(async ({type}: {type: EventType}) => { notifee.onForegroundEvent(async ({type}: {type: EventType}) => {
store.log.debug('Notifee foreground event', {type}) store.log.debug('Notifee foreground event', {type})
if (type === EventType.PRESS) { if (type === EventType.PRESS) {
+4 -14
View File
@@ -1,6 +1,6 @@
import {autorun} from 'mobx' import {autorun} from 'mobx'
import {Platform} from 'react-native' import {Platform} from 'react-native'
import {sessionClient as SessionAtpApi} from '@atproto/api' import {AtpAgent} from '@atproto/api'
import {RootStoreModel} from './models/root-store' import {RootStoreModel} from './models/root-store'
import * as libapi from './lib/api' import * as libapi from './lib/api'
import * as storage from './lib/storage' import * as storage from './lib/storage'
@@ -19,8 +19,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
libapi.doPolyfill() libapi.doPolyfill()
const api = SessionAtpApi.service(serviceUri) rootStore = new RootStoreModel(new AtpAgent({service: serviceUri}))
rootStore = new RootStoreModel(api)
try { try {
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {} data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
rootStore.log.debug('Initial hydrate', {hasSession: !!data.session}) rootStore.log.debug('Initial hydrate', {hasSession: !!data.session})
@@ -28,16 +27,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
} catch (e: any) { } catch (e: any) {
rootStore.log.error('Failed to load state from storage', e) rootStore.log.error('Failed to load state from storage', e)
} }
rootStore.attemptSessionResumption()
rootStore.session
.connect()
.then(() => {
rootStore.log.debug('Session connected')
return rootStore.fetchStateUpdate()
})
.catch((e: any) => {
rootStore.log.warn('Failed initial connect', e)
})
// track changes & save to storage // track changes & save to storage
autorun(() => { autorun(() => {
@@ -47,7 +37,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
// periodic state fetch // periodic state fetch
setInterval(() => { setInterval(() => {
rootStore.fetchStateUpdate() rootStore.updateSessionState()
}, STATE_FETCH_INTERVAL) }, STATE_FETCH_INTERVAL)
return rootStore return rootStore
+15 -13
View File
@@ -1,14 +1,4 @@
/** import AtpAgent, {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api'
* The environment is a place where services and shared dependencies between
* models live. They are made available to every model via dependency injection.
*/
// import {ReactNativeStore} from './auth'
import AtpApi, {
sessionClient as SessionAtpApi,
AppBskyEmbedImages,
AppBskyEmbedExternal,
} from '@atproto/api'
import RNFS from 'react-native-fs' import RNFS from 'react-native-fs'
import {AtUri} from '../../third-party/uri' import {AtUri} from '../../third-party/uri'
import {RootStoreModel} from '../models/root-store' import {RootStoreModel} from '../models/root-store'
@@ -20,8 +10,7 @@ import {Image} from '../../lib/images'
const TIMEOUT = 10e3 // 10s const TIMEOUT = 10e3 // 10s
export function doPolyfill() { export function doPolyfill() {
AtpApi.xrpc.fetch = fetchHandler AtpAgent.configure({fetch: fetchHandler})
SessionAtpApi.xrpc.fetch = fetchHandler
} }
export interface ExternalEmbedDraft { export interface ExternalEmbedDraft {
@@ -31,6 +20,19 @@ export interface ExternalEmbedDraft {
localThumb?: Image localThumb?: Image
} }
export async function resolveName(store: RootStoreModel, didOrHandle: string) {
if (!didOrHandle) {
throw new Error('Invalid handle: ""')
}
if (didOrHandle.startsWith('did:')) {
return didOrHandle
}
const res = await store.api.com.atproto.handle.resolve({
handle: didOrHandle,
})
return res.data.did
}
export async function post( export async function post(
store: RootStoreModel, store: RootStoreModel,
text: string, text: string,
+2
View File
@@ -258,6 +258,7 @@ export class FeedModel {
* Nuke all data * Nuke all data
*/ */
clear() { clear() {
this.rootStore.log.debug('FeedModel:clear')
this.isLoading = false this.isLoading = false
this.isRefreshing = false this.isRefreshing = false
this.hasNewLatest = false this.hasNewLatest = false
@@ -273,6 +274,7 @@ export class FeedModel {
* Load for first render * Load for first render
*/ */
async setup(isRefreshing = false) { async setup(isRefreshing = false) {
this.rootStore.log.debug('FeedModel:setup', {isRefreshing})
if (isRefreshing) { if (isRefreshing) {
this.isRefreshing = true // set optimistically for UI this.isRefreshing = true // set optimistically for UI
} }
+6 -8
View File
@@ -29,6 +29,8 @@ export class MeModel {
} }
clear() { clear() {
this.mainFeed.clear()
this.notifications.clear()
this.did = '' this.did = ''
this.handle = '' this.handle = ''
this.displayName = '' this.displayName = ''
@@ -77,9 +79,10 @@ export class MeModel {
async load() { async load() {
const sess = this.rootStore.session const sess = this.rootStore.session
if (sess.hasSession && sess.data) { this.rootStore.log.debug('MeModel:load', {hasSession: sess.hasSession})
this.did = sess.data.did || '' if (sess.hasSession) {
this.handle = sess.data.handle this.did = sess.currentSession?.did || ''
this.handle = sess.currentSession?.handle || ''
const profile = await this.rootStore.api.app.bsky.actor.getProfile({ const profile = await this.rootStore.api.app.bsky.actor.getProfile({
actor: this.did, actor: this.did,
}) })
@@ -94,11 +97,6 @@ export class MeModel {
this.avatar = '' this.avatar = ''
} }
}) })
this.mainFeed.clear()
this.mainFeed = new FeedModel(this.rootStore, 'home', {
algorithm: 'reverse-chronological',
})
this.notifications = new NotificationsViewModel(this.rootStore, {})
await Promise.all([ await Promise.all([
this.mainFeed.setup().catch(e => { this.mainFeed.setup().catch(e => {
this.rootStore.log.error('Failed to setup main feed model', e) this.rootStore.log.error('Failed to setup main feed model', e)
+19 -1
View File
@@ -234,10 +234,26 @@ export class NotificationsViewModel {
// public api // public api
// = // =
/**
* Nuke all data
*/
clear() {
this.rootStore.log.debug('NotificationsModel:clear')
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = false
this.error = ''
this.hasMore = true
this.loadMoreCursor = undefined
this.notifications = []
this.mostRecentNotification = undefined
}
/** /**
* Load for first render * Load for first render
*/ */
async setup(isRefreshing = false) { async setup(isRefreshing = false) {
this.rootStore.log.debug('NotificationsModel:setup', {isRefreshing})
if (isRefreshing) { if (isRefreshing) {
this.isRefreshing = true // set optimistically for UI this.isRefreshing = true // set optimistically for UI
} }
@@ -299,7 +315,9 @@ export class NotificationsViewModel {
async getNewMostRecent(): Promise<NotificationsViewItemModel | undefined> { async getNewMostRecent(): Promise<NotificationsViewItemModel | undefined> {
let old = this.mostRecentNotification let old = this.mostRecentNotification
const res = await this.rootStore.api.app.bsky.notification.list({limit: 1}) const res = await this.rootStore.api.app.bsky.notification.list({
limit: 1,
})
if ( if (
!res.data.notifications[0] || !res.data.notifications[0] ||
old?.uri === res.data.notifications[0].uri old?.uri === res.data.notifications[0].uri
+1 -1
View File
@@ -291,7 +291,7 @@ export class PostThreadViewModel {
const urip = new AtUri(this.params.uri) const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) { if (!urip.host.startsWith('did:')) {
try { try {
urip.host = await this.rootStore.resolveName(urip.host) urip.host = await apilib.resolveName(this.rootStore, urip.host)
} catch (e: any) { } catch (e: any) {
this.error = e.toString() this.error = e.toString()
} }
+3 -1
View File
@@ -31,7 +31,9 @@ export class ProfilesViewModel {
} }
} }
try { try {
const promise = this.rootStore.api.app.bsky.actor.getProfile({actor: did}) const promise = this.rootStore.api.app.bsky.actor.getProfile({
actor: did,
})
this.cache.set(did, promise) this.cache.set(did, promise)
const res = await promise const res = await promise
this.cache.set(did, res) this.cache.set(did, res)
+2 -1
View File
@@ -3,6 +3,7 @@ import {AtUri} from '../../third-party/uri'
import {AppBskyFeedGetRepostedBy as GetRepostedBy} from '@atproto/api' import {AppBskyFeedGetRepostedBy as GetRepostedBy} from '@atproto/api'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import {cleanError} from '../../lib/strings' import {cleanError} from '../../lib/strings'
import * as apilib from '../lib/api'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
@@ -93,7 +94,7 @@ export class RepostedByViewModel {
const urip = new AtUri(this.params.uri) const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) { if (!urip.host.startsWith('did:')) {
try { try {
urip.host = await this.rootStore.resolveName(urip.host) urip.host = await apilib.resolveName(this.rootStore, urip.host)
} catch (e: any) { } catch (e: any) {
this.error = e.toString() this.error = e.toString()
} }
+76 -56
View File
@@ -3,10 +3,7 @@
*/ */
import {makeAutoObservable} from 'mobx' import {makeAutoObservable} from 'mobx'
import { import {AtpAgent} from '@atproto/api'
sessionClient as SessionAtpApi,
SessionServiceClient,
} from '@atproto/api'
import {createContext, useContext} from 'react' import {createContext, useContext} from 'react'
import {DeviceEventEmitter, EmitterSubscription} from 'react-native' import {DeviceEventEmitter, EmitterSubscription} from 'react-native'
import BackgroundFetch from 'react-native-background-fetch' import BackgroundFetch from 'react-native-background-fetch'
@@ -19,10 +16,9 @@ import {ProfilesViewModel} from './profiles-view'
import {LinkMetasViewModel} from './link-metas-view' import {LinkMetasViewModel} from './link-metas-view'
import {MeModel} from './me' import {MeModel} from './me'
import {OnboardModel} from './onboard' import {OnboardModel} from './onboard'
import {isNetworkError} from '../../lib/errors'
export class RootStoreModel { export class RootStoreModel {
api: SessionServiceClient agent: AtpAgent
log = new LogModel() log = new LogModel()
session = new SessionModel(this) session = new SessionModel(this)
nav = new NavigationModel() nav = new NavigationModel()
@@ -32,62 +28,18 @@ export class RootStoreModel {
profiles = new ProfilesViewModel(this) profiles = new ProfilesViewModel(this)
linkMetas = new LinkMetasViewModel(this) linkMetas = new LinkMetasViewModel(this)
constructor(api: SessionServiceClient) { constructor(agent: AtpAgent) {
this.api = api // to keep typescript from whining this.agent = agent
this.setAPI(api)
makeAutoObservable(this, { makeAutoObservable(this, {
api: false, api: false,
resolveName: false,
serialize: false, serialize: false,
hydrate: false, hydrate: false,
}) })
this.initBgFetch() this.initBgFetch()
} }
setAPI(api: SessionServiceClient) { get api() {
if (this.api) { return this.agent.api
this.api.sessionManager.removeAllListeners('session')
}
this.api = api
this.api.sessionManager.on('session', this.onSessionChange.bind(this))
}
onSessionChange() {
if (!this.api.sessionManager.session && this.session.hasSession) {
this.log.debug('Session invalidated, logging the user out')
this.session.clear()
} else if (this.api.sessionManager.session) {
this.log.debug('Session refreshed, updating auth tokens')
this.session.updateAuthTokens(this.api.sessionManager.session)
}
}
async resolveName(didOrHandle: string) {
if (!didOrHandle) {
throw new Error('Invalid handle: ""')
}
if (didOrHandle.startsWith('did:')) {
return didOrHandle
}
const res = await this.api.com.atproto.handle.resolve({handle: didOrHandle})
return res.data.did
}
async fetchStateUpdate() {
if (!this.session.hasSession) {
return
}
try {
if (!this.session.online) {
await this.session.connect()
}
await this.me.fetchNotifications()
} catch (e: any) {
if (isNetworkError(e)) {
this.session.setOnline(false) // connection lost
}
this.log.error('Failed to fetch latest state', e)
}
} }
serialize(): unknown { serialize(): unknown {
@@ -124,12 +76,72 @@ export class RootStoreModel {
} }
} }
clearAll() { /**
* Called during init to resume any stored session.
*/
async attemptSessionResumption() {
this.log.debug('RootStoreModel:attemptSessionResumption')
try {
await this.session.attemptSessionResumption()
this.log.debug('Session initialized', {
hasSession: this.session.hasSession,
})
this.updateSessionState()
} catch (e: any) {
this.log.warn('Failed to initialize session', e)
}
}
/**
* Called by the session model. Refreshes session-oriented state.
*/
async handleSessionChange(agent: AtpAgent) {
this.log.debug('RootStoreModel:handleSessionChange')
this.agent = agent
this.nav.clear()
this.me.clear()
await this.me.load()
}
/**
* Called by the session model. Handles session drops by informing the user.
*/
async handleSessionDrop() {
this.log.debug('RootStoreModel:handleSessionDrop')
this.nav.clear()
this.me.clear()
this.emitSessionDropped()
}
/**
* Clears all session-oriented state.
*/
clearAllSessionState() {
this.log.debug('RootStoreModel:clearAllSessionState')
this.session.clear() this.session.clear()
this.nav.clear() this.nav.clear()
this.me.clear() this.me.clear()
} }
/**
* Periodic poll for new session state.
*/
async updateSessionState() {
if (!this.session.hasSession) {
return
}
try {
await this.me.fetchNotifications()
} catch (e: any) {
this.log.error('Failed to fetch latest state', e)
}
}
// global event bus
// =
// - some events need to be passed around between views and models
// in order to keep state in sync; these methods are for that
onPostDeleted(handler: (uri: string) => void): EmitterSubscription { onPostDeleted(handler: (uri: string) => void): EmitterSubscription {
return DeviceEventEmitter.addListener('post-deleted', handler) return DeviceEventEmitter.addListener('post-deleted', handler)
} }
@@ -138,6 +150,14 @@ export class RootStoreModel {
DeviceEventEmitter.emit('post-deleted', uri) DeviceEventEmitter.emit('post-deleted', uri)
} }
onSessionDropped(handler: () => void): EmitterSubscription {
return DeviceEventEmitter.addListener('session-dropped', handler)
}
emitSessionDropped() {
DeviceEventEmitter.emit('session-dropped')
}
// background fetch // background fetch
// = // =
// - we use this to poll for unread notifications, which is not "ideal" behavior but // - we use this to poll for unread notifications, which is not "ideal" behavior but
@@ -172,7 +192,7 @@ export class RootStoreModel {
} }
const throwawayInst = new RootStoreModel( const throwawayInst = new RootStoreModel(
SessionAtpApi.service('http://localhost'), new AtpAgent({service: 'http://localhost'}),
) // this will be replaced by the loader, we just need to supply a value at init ) // this will be replaced by the loader, we just need to supply a value at init
const RootStoreContext = createContext<RootStoreModel>(throwawayInst) const RootStoreContext = createContext<RootStoreModel>(throwawayInst)
export const RootStoreProvider = RootStoreContext.Provider export const RootStoreProvider = RootStoreContext.Provider
+186 -199
View File
@@ -1,24 +1,22 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable} from 'mobx'
import AtpApi, { import {
sessionClient as SessionAtpApi, AtpAgent,
Session, AtpSessionEvent,
AtpSessionData,
ComAtprotoServerGetAccountsConfig as GetAccountsConfig, ComAtprotoServerGetAccountsConfig as GetAccountsConfig,
} from '@atproto/api' } from '@atproto/api'
import normalizeUrl from 'normalize-url'
import {isObj, hasProp} from '../lib/type-guards' import {isObj, hasProp} from '../lib/type-guards'
import {z} from 'zod' import {z} from 'zod'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import {isNetworkError} from '../../lib/errors'
export type ServiceDescription = GetAccountsConfig.OutputSchema export type ServiceDescription = GetAccountsConfig.OutputSchema
export const sessionData = z.object({ export const activeSession = z.object({
service: z.string(), service: z.string(),
refreshJwt: z.string(),
accessJwt: z.string(),
handle: z.string(),
did: z.string(), did: z.string(),
}) })
export type SessionData = z.infer<typeof sessionData> export type ActiveSession = z.infer<typeof activeSession>
export const accountData = z.object({ export const accountData = z.object({
service: z.string(), service: z.string(),
@@ -31,18 +29,20 @@ export const accountData = z.object({
}) })
export type AccountData = z.infer<typeof accountData> export type AccountData = z.infer<typeof accountData>
interface AdditionalAccountData {
displayName?: string
aviUrl?: string
}
export class SessionModel { export class SessionModel {
/** /**
* Current session data * Currently-active session
*/ */
data: SessionData | null = null data: ActiveSession | null = null
/** /**
* A listing of the currently & previous sessions, used for account switching * A listing of the currently & previous sessions
*/ */
accounts: AccountData[] = [] accounts: AccountData[] = []
online = false
attemptingConnect = false
private _connectPromise: Promise<boolean> | undefined
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, { makeAutoObservable(this, {
@@ -52,8 +52,22 @@ export class SessionModel {
}) })
} }
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 hasSession() { get hasSession() {
return this.data !== null return !!this.currentSession && !!this.rootStore.agent.session
} }
get hasAccounts() { get hasAccounts() {
@@ -74,8 +88,8 @@ export class SessionModel {
hydrate(v: unknown) { hydrate(v: unknown) {
this.accounts = [] this.accounts = []
if (isObj(v)) { if (isObj(v)) {
if (hasProp(v, 'data') && sessionData.safeParse(v.data)) { if (hasProp(v, 'data') && activeSession.safeParse(v.data)) {
this.data = v.data as SessionData this.data = v.data as ActiveSession
} }
if (hasProp(v, 'accounts') && Array.isArray(v.accounts)) { if (hasProp(v, 'accounts') && Array.isArray(v.accounts)) {
for (const account of v.accounts) { for (const account of v.accounts) {
@@ -89,93 +103,91 @@ export class SessionModel {
clear() { clear() {
this.data = null this.data = null
this.setOnline(false)
}
setState(data: SessionData) {
this.data = data
this.addSessionToAccounts()
}
setOnline(online: boolean, attemptingConnect?: boolean) {
this.online = online
if (typeof attemptingConnect === 'boolean') {
this.attemptingConnect = attemptingConnect
}
}
updateAuthTokens(session: Session) {
if (this.data) {
this.setState({
...this.data,
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt,
})
}
} }
/** /**
* Sets up the XRPC API, must be called before connecting to a service * Attempts to resume the previous session loaded from storage
*/ */
private configureApi(): boolean { async attemptSessionResumption() {
if (!this.data) { const sess = this.currentSession
return false if (sess) {
} this.rootStore.log.debug(
'SessionModel:attemptSessionResumption found stored session',
try { )
const serviceUri = new URL(this.data.service) return this.resumeSession(sess)
const api = SessionAtpApi.service(serviceUri) } else {
api.sessionManager.set({ this.rootStore.log.debug(
refreshJwt: this.data.refreshJwt, 'SessionModel:attemptSessionResumption has no session to resume',
accessJwt: this.data.accessJwt,
})
this.rootStore.setAPI(api)
} catch (e: any) {
this.rootStore.log.error(
`Invalid service URL: ${this.data.service}. Resetting session.`,
e,
) )
this.clear()
return false
} }
return true
} }
/** /**
* Upserts the current session into the accounts * Sets the active session
*/ */
private addSessionToAccounts() { setActiveSession(agent: AtpAgent, did: string) {
if (!this.data) { this.rootStore.log.debug('SessionModel:setActiveSession')
return this.data = {
service: agent.service.toString(),
did,
} }
this.rootStore.handleSessionChange(agent)
}
/**
* Upserts a session into the accounts
*/
private persistSession(
service: string,
did: string,
event: AtpSessionEvent,
session?: AtpSessionData,
addedInfo?: AdditionalAccountData,
) {
this.rootStore.log.debug('SessionModel:persistSession', {
service,
did,
event,
hasSession: !!session,
})
// upsert the account in our listing
const existingAccount = this.accounts.find( const existingAccount = this.accounts.find(
acc => acc.service === this.data?.service && acc.did === this.data.did, account => account.service === service && account.did === did,
) )
const newAccount = { const newAccount = {
service: this.data.service, service,
refreshJwt: this.data.refreshJwt, did,
accessJwt: this.data.accessJwt, refreshJwt: session?.refreshJwt,
handle: this.data.handle, accessJwt: session?.accessJwt,
did: this.data.did, handle: session?.handle || existingAccount?.handle || '',
displayName: this.rootStore.me.displayName, displayName: addedInfo
aviUrl: this.rootStore.me.avatar, ? addedInfo.displayName
: existingAccount?.displayName || '',
aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '',
} }
if (!existingAccount) { if (!existingAccount) {
this.accounts.push(newAccount) this.accounts.push(newAccount)
} else { } else {
this.accounts = this.accounts this.accounts = [
.filter( newAccount,
acc => ...this.accounts.filter(
!(acc.service === this.data?.service && acc.did === this.data.did), account => !(account.service === service && account.did === did),
) ),
.concat([newAccount]) ]
}
// 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. * Clears any session tokens from the accounts; used on logout.
*/ */
private clearSessionTokensFromAccounts() { private clearSessionTokens() {
this.rootStore.log.debug('SessionModel:clearSessionTokens')
this.accounts = this.accounts.map(acct => ({ this.accounts = this.accounts.map(acct => ({
service: acct.service, service: acct.service,
handle: acct.handle, handle: acct.handle,
@@ -186,59 +198,73 @@ export class SessionModel {
} }
/** /**
* Fetches the current session from the service, if possible. * Fetches additional information about an account on load.
* Requires an existing session (.data) to be populated with access tokens.
*/ */
async connect(): Promise<boolean> { private async loadAccountInfo(agent: AtpAgent, did: string) {
if (this._connectPromise) { const res = await agent.api.app.bsky.actor
return this._connectPromise .getProfile({actor: did})
} .catch(_e => undefined)
this._connectPromise = this._connect() if (res) {
const res = await this._connectPromise return {
this._connectPromise = undefined dispayName: res.data.displayName,
return res aviUrl: res.data.avatar,
}
private async _connect(): Promise<boolean> {
this.attemptingConnect = true
if (!this.configureApi()) {
return false
}
try {
const sess = await this.rootStore.api.com.atproto.session.get()
if (sess.success && this.data && this.data.did === sess.data.did) {
this.setOnline(true, false)
if (this.rootStore.me.did !== sess.data.did) {
this.rootStore.me.clear()
}
this.rootStore.me.load().then(() => {
this.addSessionToAccounts()
})
return true // success
}
} catch (e: any) {
if (isNetworkError(e)) {
this.setOnline(false, false) // connection issue
return false
} else {
this.clear() // invalid session cached
} }
} }
this.setOnline(false, false)
return false
} }
/** /**
* Helper to fetch the accounts config settings from an account. * Helper to fetch the accounts config settings from an account.
*/ */
async describeService(service: string): Promise<ServiceDescription> { async describeService(service: string): Promise<ServiceDescription> {
const api = AtpApi.service(service) const agent = new AtpAgent({service})
const res = await api.com.atproto.server.getAccountsConfig({}) const res = await agent.api.com.atproto.server.getAccountsConfig({})
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.rootStore.log.debug('SessionModel:resumeSession')
if (!(account.accessJwt && account.refreshJwt && account.service)) {
this.rootStore.log.debug(
'SessionModel:resumeSession aborted due to lack of access tokens',
)
return false
}
const agent = new AtpAgent({
service: account.service,
persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => {
this.persistSession(account.service, account.did, evt, sess)
},
})
try {
await agent.resumeSession({
accessJwt: account.accessJwt,
refreshJwt: account.refreshJwt,
did: account.did,
handle: account.handle,
})
const addedInfo = await this.loadAccountInfo(agent, account.did)
this.persistSession(
account.service,
account.did,
'create',
agent.session,
addedInfo,
)
this.rootStore.log.debug('SessionModel:resumeSession succeeded')
} catch (e: any) {
this.rootStore.log.debug('SessionModel:resumeSession failed', {
error: e.toString(),
})
return false
}
this.setActiveSession(agent, account.did)
return true
}
/** /**
* Create a new session. * Create a new session.
*/ */
@@ -251,66 +277,22 @@ export class SessionModel {
identifier: string identifier: string
password: string password: string
}) { }) {
const api = AtpApi.service(service) this.rootStore.log.debug('SessionModel:login')
const res = await api.com.atproto.session.create({identifier, password}) const agent = new AtpAgent({service})
if (res.data.accessJwt && res.data.refreshJwt) { await agent.login({identifier, password})
this.setState({ if (!agent.session) {
service: service, throw new Error('Failed to establish session')
accessJwt: res.data.accessJwt,
refreshJwt: res.data.refreshJwt,
handle: res.data.handle,
did: res.data.did,
})
this.configureApi()
this.setOnline(true, false)
this.rootStore.me.load().then(() => {
this.addSessionToAccounts()
})
} }
} const did = agent.session.did
const addedInfo = await this.loadAccountInfo(agent, did)
/** this.persistSession(service, did, 'create', agent.session, addedInfo)
* Attempt to resume a session that we still have access tokens for. agent.setPersistSessionHandler(
*/ (evt: AtpSessionEvent, sess?: AtpSessionData) => {
async resumeSession(account: AccountData): Promise<boolean> { this.persistSession(service, did, evt, sess)
if (!(account.accessJwt && account.refreshJwt && account.service)) { },
return false )
} this.setActiveSession(agent, did)
this.rootStore.log.debug('SessionModel:login succeeded')
// test that the session is good
const api = SessionAtpApi.service(account.service)
api.sessionManager.set({
refreshJwt: account.refreshJwt,
accessJwt: account.accessJwt,
})
try {
const sess = await api.com.atproto.session.get()
if (
!sess.success ||
sess.data.did !== account.did ||
!api.sessionManager.session
) {
return false
}
// copy over the access tokens, as they may have refreshed during the .get() above
runInAction(() => {
account.refreshJwt = api.sessionManager.session?.refreshJwt
account.accessJwt = api.sessionManager.session?.accessJwt
})
} catch (_e) {
return false
}
// session is good, connect
this.setState({
service: account.service,
accessJwt: account.accessJwt,
refreshJwt: account.refreshJwt,
handle: account.handle,
did: account.did,
})
return this.connect()
} }
async createAccount({ async createAccount({
@@ -326,33 +308,38 @@ export class SessionModel {
handle: string handle: string
inviteCode?: string inviteCode?: string
}) { }) {
const api = AtpApi.service(service) this.rootStore.log.debug('SessionModel:createAccount')
const res = await api.com.atproto.account.create({ const agent = new AtpAgent({service})
await agent.createAccount({
handle, handle,
password, password,
email, email,
inviteCode, inviteCode,
}) })
if (res.data.accessJwt && res.data.refreshJwt) { if (!agent.session) {
this.setState({ throw new Error('Failed to establish session')
service: service,
accessJwt: res.data.accessJwt,
refreshJwt: res.data.refreshJwt,
handle: res.data.handle,
did: res.data.did,
})
this.rootStore.onboard.start()
this.configureApi()
this.rootStore.me.load().then(() => {
this.addSessionToAccounts()
})
} }
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)
},
)
this.setActiveSession(agent, did)
this.rootStore.onboard.start()
this.rootStore.log.debug('SessionModel:createAccount succeeded')
} }
/** /**
* Close all sessions across all accounts. * Close all sessions across all accounts.
*/ */
async logout() { async logout() {
this.rootStore.log.debug('SessionModel:logout')
// TODO
// need to evaluate why deleting the session has caused errors at times
// -prf
/*if (this.hasSession) { /*if (this.hasSession) {
this.rootStore.api.com.atproto.session.delete().catch((e: any) => { this.rootStore.api.com.atproto.session.delete().catch((e: any) => {
this.rootStore.log.warn( this.rootStore.log.warn(
@@ -361,7 +348,7 @@ export class SessionModel {
) )
}) })
}*/ }*/
this.clearSessionTokensFromAccounts() this.clearSessionTokens()
this.rootStore.clearAll() this.rootStore.clearAllSessionState()
} }
} }
+2 -1
View File
@@ -3,6 +3,7 @@ import {AtUri} from '../../third-party/uri'
import {AppBskyFeedGetVotes as GetVotes} from '@atproto/api' import {AppBskyFeedGetVotes as GetVotes} from '@atproto/api'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import {cleanError} from '../../lib/strings' import {cleanError} from '../../lib/strings'
import * as apilib from '../lib/api'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
@@ -90,7 +91,7 @@ export class VotesViewModel {
const urip = new AtUri(this.params.uri) const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) { if (!urip.host.startsWith('did:')) {
try { try {
urip.host = await this.rootStore.resolveName(urip.host) urip.host = await apilib.resolveName(this.rootStore, urip.host)
} catch (e: any) { } catch (e: any) {
this.error = e.toString() this.error = e.toString()
} }
+8 -5
View File
@@ -10,7 +10,7 @@ import {
} from 'react-native' } from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import * as EmailValidator from 'email-validator' import * as EmailValidator from 'email-validator'
import AtpApi from '@atproto/api' import AtpAgent from '@atproto/api'
import {useAnalytics} from '@segment/analytics-react-native' import {useAnalytics} from '@segment/analytics-react-native'
import {LogoTextHero} from './Logo' import {LogoTextHero} from './Logo'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
@@ -488,8 +488,8 @@ const ForgotPasswordForm = ({
setIsProcessing(true) setIsProcessing(true)
try { try {
const api = AtpApi.service(serviceUrl) const agent = new AtpAgent({service: serviceUrl})
await api.com.atproto.account.requestPasswordReset({email}) await agent.api.com.atproto.account.requestPasswordReset({email})
onEmailSent() onEmailSent()
} catch (e: any) { } catch (e: any) {
const errMsg = e.toString() const errMsg = e.toString()
@@ -625,8 +625,11 @@ const SetNewPasswordForm = ({
setIsProcessing(true) setIsProcessing(true)
try { try {
const api = AtpApi.service(serviceUrl) const agent = new AtpAgent({service: serviceUrl})
await api.com.atproto.account.resetPassword({token: resetCode, password}) await agent.api.com.atproto.account.resetPassword({
token: resetCode,
password,
})
onPasswordSet() onPasswordSet()
} catch (e: any) { } catch (e: any) {
const errMsg = e.toString() const errMsg = e.toString()
+1 -1
View File
@@ -30,7 +30,7 @@ export function UserAvatar({
avatar?: string | null avatar?: string | null
onSelectNewAvatar?: (img: PickedImage) => void onSelectNewAvatar?: (img: PickedImage) => void
}) { }) {
const initials = getInitials(displayName || handle) const initials = getInitials(displayName || handle || '')
const pal = usePalette('default') const pal = usePalette('default')
const renderSvg = (svgSize: number, svgInitials: string) => ( const renderSvg = (svgSize: number, svgInitials: string) => (
<Svg width={svgSize} height={svgSize} viewBox="0 0 100 100"> <Svg width={svgSize} height={svgSize} viewBox="0 0 100 100">
+1 -30
View File
@@ -1,11 +1,6 @@
import React from 'react' import React from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import { import {StyleSheet, TouchableOpacity, View} from 'react-native'
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {UserAvatar} from './UserAvatar' import {UserAvatar} from './UserAvatar'
import {Text} from './text/Text' import {Text} from './text/Text'
@@ -40,11 +35,6 @@ export const ViewHeader = observer(function ViewHeader({
const onPressSearch = () => { const onPressSearch = () => {
store.nav.navigate('/search') store.nav.navigate('/search')
} }
const onPressReconnect = () => {
store.session.connect().catch(e => {
store.log.warn('Failed to reconnect to server', e)
})
}
if (typeof canGoBack === 'undefined') { if (typeof canGoBack === 'undefined') {
canGoBack = store.nav.tab.canGoBack canGoBack = store.nav.tab.canGoBack
} }
@@ -89,25 +79,6 @@ export const ViewHeader = observer(function ViewHeader({
style={styles.btn}> style={styles.btn}>
<MagnifyingGlassIcon size={21} strokeWidth={3} style={pal.text} /> <MagnifyingGlassIcon size={21} strokeWidth={3} style={pal.text} />
</TouchableOpacity> </TouchableOpacity>
{!store.session.online ? (
<TouchableOpacity style={styles.btn} onPress={onPressReconnect}>
{store.session.attemptingConnect ? (
<ActivityIndicator />
) : (
<>
<FontAwesomeIcon icon="signal" style={pal.text} size={16} />
<FontAwesomeIcon
icon="x"
style={[
styles.littleXIcon,
{backgroundColor: pal.colors.background},
]}
size={8}
/>
</>
)}
</TouchableOpacity>
) : undefined}
</View> </View>
) )
}) })
+18 -5
View File
@@ -19,10 +19,10 @@
jsonpointer "^5.0.0" jsonpointer "^5.0.0"
leven "^3.1.0" leven "^3.1.0"
"@atproto/api@^0.0.8": "@atproto/api@^0.1.0":
version "0.0.8" version "0.1.0"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.0.8.tgz#ff01bb193cd1dad422916572ff595a625fa3cc06" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.1.0.tgz#0677cdde0a8b943b904dbc3f7f7357b329eca48a"
integrity sha512-qEnrzy8vKnt3yEjRCmKUnTvW6GKPB9pB5PP1+qIfUnQ7Sqrb793LtaSXPk9ZnrvtL3fsxYnfUY+7KB2X4osSyA== integrity sha512-+vKAEEgh3GgLfK/QZGAoQKesmhYcCsuiiEzr1miCQAuf7zfTsbX27zHERIuEVT+TqTH98MNbd3uyzF/NAbYMwQ==
dependencies: dependencies:
"@atproto/xrpc" "*" "@atproto/xrpc" "*"
typed-emitter "^2.1.0" typed-emitter "^2.1.0"
@@ -180,7 +180,7 @@
mime-types "^2.1.35" mime-types "^2.1.35"
zod "^3.14.2" zod "^3.14.2"
"@atproto/xrpc@*", "@atproto/xrpc@^0.0.3": "@atproto/xrpc@*":
version "0.0.3" version "0.0.3"
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.0.3.tgz#510028753d51dffd754ee4f96897b74bcba50bda" resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.0.3.tgz#510028753d51dffd754ee4f96897b74bcba50bda"
integrity sha512-dOxtUqUfXOalPhtc1jyIvzCasd+iXwt/Sp+QIAy5qOTCxF9IvXuJ9bNFY49NUPEWlagaRtL8glQhbR307GHcuA== integrity sha512-dOxtUqUfXOalPhtc1jyIvzCasd+iXwt/Sp+QIAy5qOTCxF9IvXuJ9bNFY49NUPEWlagaRtL8glQhbR307GHcuA==
@@ -188,6 +188,14 @@
"@atproto/lexicon" "*" "@atproto/lexicon" "*"
zod "^3.14.2" zod "^3.14.2"
"@atproto/xrpc@^0.0.4":
version "0.0.4"
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.0.4.tgz#d7dd45cdb21e29b9715ca30eb18320548f293413"
integrity sha512-Hxh+GgZx21Zvlb2RMlSlJDd3r3GR0vAS6OOZPW2xzWiVHsetb9ZlFB6D0AeAPj2R+U2UUkmdUR8G3U/nkgnQFA==
dependencies:
"@atproto/lexicon" "*"
zod "^3.14.2"
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.18.6", "@babel/code-frame@^7.8.3": "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.18.6", "@babel/code-frame@^7.8.3":
version "7.18.6" version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a"
@@ -9778,6 +9786,11 @@ normalize-url@^6.0.1:
resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a"
integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==
normalize-url@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-8.0.0.tgz#593dbd284f743e8dcf6a5ddf8fadff149c82701a"
integrity sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==
npm-run-path@^2.0.0: npm-run-path@^2.0.0:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"