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:
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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__",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,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>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user