diff --git a/__mocks__/state-mock.ts b/__mocks__/state-mock.ts index f5676288f5..b0b6e05a66 100644 --- a/__mocks__/state-mock.ts +++ b/__mocks__/state-mock.ts @@ -648,8 +648,8 @@ export const mockedRootStore = { resolveName: jest.fn(), serialize: jest.fn(), hydrate: jest.fn(), - fetchStateUpdate: jest.fn(), - clearAll: jest.fn(), + updateSessionState: jest.fn(), + clearAllSessionState: jest.fn(), session: mockedSessionStore, nav: mockedNavigationStore, shell: mockedShellStore, diff --git a/__tests__/accounts.test.tsx b/__tests__/accounts.test.tsx index 8e656a0304..4cd0b961cf 100644 --- a/__tests__/accounts.test.tsx +++ b/__tests__/accounts.test.tsx @@ -4,7 +4,7 @@ import {cleanup, fireEvent, render, waitFor} from '../jest/test-utils' import {createServer, TestPDS} from '../jest/test-pds' import {RootStoreModel, setupState} from '../src/state' -const WAIT_OPTS = {timeout: 5e3} +const WAIT_OPTS = {timeout: 10e3} describe('Account flows', () => { let pds: TestPDS | undefined diff --git a/__tests__/state/models/link-metas-view.test.ts b/__tests__/state/models/link-metas-view.test.ts index 0e5fb8da57..80cdebb694 100644 --- a/__tests__/state/models/link-metas-view.test.ts +++ b/__tests__/state/models/link-metas-view.test.ts @@ -2,7 +2,7 @@ import {RootStoreModel} from '../../../src/state/models/root-store' import {LinkMetasViewModel} from '../../../src/state/models/link-metas-view' import * as LinkMetaLib 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' describe('LinkMetasViewModel', () => { @@ -17,8 +17,7 @@ describe('LinkMetasViewModel', () => { } beforeEach(() => { - const api = sessionClient.service(DEFAULT_SERVICE) as SessionServiceClient - rootStore = new RootStoreModel(api) + rootStore = new RootStoreModel(new AtpAgent({service: DEFAULT_SERVICE})) viewModel = new LinkMetasViewModel(rootStore) }) diff --git a/__tests__/state/models/me.test.ts b/__tests__/state/models/me.test.ts index b69e434773..7e9a6670bb 100644 --- a/__tests__/state/models/me.test.ts +++ b/__tests__/state/models/me.test.ts @@ -1,106 +1,110 @@ -import {RootStoreModel} from '../../../src/state/models/root-store' -import {MeModel} from '../../../src/state/models/me' -import {NotificationsViewModel} from './../../../src/state/models/notifications-view' -import {sessionClient, SessionServiceClient} from '@atproto/api' -import {DEFAULT_SERVICE} from './../../../src/state/index' +import {createServer, TestPDS} from '../../../jest/test-pds' +import {RootStoreModel, setupState} from '../../../src/state' +import {NotificationsViewModel} from '../../../src/state/models/notifications-view' describe('MeModel', () => { + let pds: TestPDS | undefined let rootStore: RootStoreModel - let meModel: MeModel - - beforeEach(() => { - const api = sessionClient.service(DEFAULT_SERVICE) as SessionServiceClient - rootStore = new RootStoreModel(api) - meModel = new MeModel(rootStore) + beforeAll(async () => { + jest.useFakeTimers() + pds = await createServer() + rootStore = await setupState(pds.pdsUrl) }) - afterAll(() => { + afterAll(async () => { jest.clearAllMocks() + await pds?.close() }) it('should clear() correctly', () => { - meModel.did = '123' - meModel.handle = 'handle' - meModel.displayName = 'John Doe' - meModel.description = 'description' - meModel.avatar = 'avatar' - meModel.notificationCount = 1 - meModel.clear() - expect(meModel.did).toEqual('') - expect(meModel.handle).toEqual('') - expect(meModel.displayName).toEqual('') - expect(meModel.description).toEqual('') - expect(meModel.avatar).toEqual('') - expect(meModel.notificationCount).toEqual(0) + rootStore.me.did = '123' + rootStore.me.handle = 'handle' + rootStore.me.displayName = 'John Doe' + rootStore.me.description = 'description' + rootStore.me.avatar = 'avatar' + rootStore.me.notificationCount = 1 + rootStore.me.clear() + expect(rootStore.me.did).toEqual('') + expect(rootStore.me.handle).toEqual('') + expect(rootStore.me.displayName).toEqual('') + expect(rootStore.me.description).toEqual('') + expect(rootStore.me.avatar).toEqual('') + expect(rootStore.me.notificationCount).toEqual(0) }) it('should hydrate() successfully with valid properties', () => { - meModel.hydrate({ + rootStore.me.clear() + rootStore.me.hydrate({ did: '123', handle: 'handle', displayName: 'John Doe', description: 'description', avatar: 'avatar', }) - 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') + expect(rootStore.me.did).toEqual('123') + expect(rootStore.me.handle).toEqual('handle') + expect(rootStore.me.displayName).toEqual('John Doe') + expect(rootStore.me.description).toEqual('description') + expect(rootStore.me.avatar).toEqual('avatar') }) it('should not hydrate() with invalid properties', () => { - meModel.hydrate({ + rootStore.me.clear() + rootStore.me.hydrate({ did: '', handle: 'handle', displayName: 'John Doe', description: 'description', avatar: 'avatar', }) - expect(meModel.did).toEqual('') - expect(meModel.handle).toEqual('') - expect(meModel.displayName).toEqual('') - expect(meModel.description).toEqual('') - expect(meModel.avatar).toEqual('') + expect(rootStore.me.did).toEqual('') + expect(rootStore.me.handle).toEqual('') + expect(rootStore.me.displayName).toEqual('') + expect(rootStore.me.description).toEqual('') + expect(rootStore.me.avatar).toEqual('') - meModel.hydrate({ + rootStore.me.hydrate({ did: '123', displayName: 'John Doe', description: 'description', avatar: 'avatar', }) - expect(meModel.did).toEqual('') - expect(meModel.handle).toEqual('') - expect(meModel.displayName).toEqual('') - expect(meModel.description).toEqual('') - expect(meModel.avatar).toEqual('') + expect(rootStore.me.did).toEqual('') + expect(rootStore.me.handle).toEqual('') + expect(rootStore.me.displayName).toEqual('') + expect(rootStore.me.description).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 () => { - jest - .spyOn(rootStore.api.app.bsky.actor, 'getProfile') - .mockImplementationOnce((): Promise => { - return Promise.resolve({ - data: { - displayName: 'John Doe', - description: 'description', - avatar: 'avatar', - }, - }) - }) - rootStore.session.data = { - did: '123', - handle: 'handle', - service: 'test service', - accessJwt: 'test token', - refreshJwt: 'test token', - } - 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') + await rootStore.session.login({ + service: pds?.pdsUrl || '', + identifier: 'alice.test', + password: 'hunter2', + }) + + await rootStore.me.load() + expect(typeof rootStore.me.did).toEqual('string') + expect(rootStore.me.handle).toEqual('alice.test') + expect(rootStore.me.displayName).toEqual('Alice') + expect(rootStore.me.description).toEqual('Test user 1') + expect(rootStore.me.avatar).toEqual('') }) it('should load() successfully without profile data', async () => { @@ -111,55 +115,32 @@ describe('MeModel', () => { data: null, }) }) - rootStore.session.data = { - did: '123', - handle: 'handle', - service: 'test service', - accessJwt: 'test token', - refreshJwt: 'test token', - } - 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('') + await rootStore.me.load() + expect(typeof rootStore.me.did).toEqual('string') + expect(rootStore.me.handle).toEqual('alice.test') + expect(rootStore.me.displayName).toEqual('') + expect(rootStore.me.description).toEqual('') + expect(rootStore.me.avatar).toEqual('') }) it('should load() to nothing when no session', async () => { - rootStore.session.data = null - await meModel.load() - expect(meModel.did).toEqual('') - expect(meModel.handle).toEqual('') - expect(meModel.displayName).toEqual('') - expect(meModel.description).toEqual('') - expect(meModel.avatar).toEqual('') - expect(meModel.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', - }) + await rootStore.session.logout() + await rootStore.me.load() + expect(rootStore.me.did).toEqual('') + expect(rootStore.me.handle).toEqual('') + expect(rootStore.me.displayName).toEqual('') + expect(rootStore.me.description).toEqual('') + expect(rootStore.me.avatar).toEqual('') + expect(rootStore.me.notificationCount).toEqual(0) }) it('should clearNotificationCount() successfully', () => { - meModel.clearNotificationCount() - expect(meModel.notificationCount).toBe(0) + rootStore.me.clearNotificationCount() + expect(rootStore.me.notificationCount).toBe(0) }) it('should update notifs count with fetchStateUpdate()', async () => { - meModel.notifications = { + rootStore.me.notifications = { refresh: jest.fn().mockResolvedValue({}), } as unknown as NotificationsViewModel @@ -173,8 +154,8 @@ describe('MeModel', () => { }) }) - await meModel.fetchNotifications() - expect(meModel.notificationCount).toBe(1) - expect(meModel.notifications.refresh).toHaveBeenCalled() + await rootStore.me.fetchNotifications() + expect(rootStore.me.notificationCount).toBe(1) + expect(rootStore.me.notifications.refresh).toHaveBeenCalled() }) }) diff --git a/__tests__/state/models/root-store.test.ts b/__tests__/state/models/root-store.test.ts index 56eec9cdf7..e9345c95cc 100644 --- a/__tests__/state/models/root-store.test.ts +++ b/__tests__/state/models/root-store.test.ts @@ -17,7 +17,7 @@ describe('rootStore', () => { }) it('should call the clearAll() resets state correctly', () => { - rootStore.clearAll() + rootStore.clearAllSessionState() expect(rootStore.session.data).toEqual(null) expect(rootStore.nav.tabs).toEqual([ diff --git a/jest/test-pds.ts b/jest/test-pds.ts index 01b37efb04..f11bc6cc42 100644 --- a/jest/test-pds.ts +++ b/jest/test-pds.ts @@ -8,7 +8,7 @@ import PDSServer, { ServerConfig as PDSServerConfig, } from '@atproto/pds' import * as plc from '@atproto/plc' -import AtpApi, {ServiceClient} from '@atproto/api' +import AtpAgent from '@atproto/api' export interface TestUser { email: string @@ -16,7 +16,7 @@ export interface TestUser { declarationCid: string handle: string password: string - api: ServiceClient + agent: AtpAgent } export interface TestUsers { @@ -112,11 +112,11 @@ export async function createServer(): Promise { async function genMockData(pdsUrl: string): Promise { const date = dateGen() - const clients = { - loggedout: AtpApi.service(pdsUrl), - alice: AtpApi.service(pdsUrl), - bob: AtpApi.service(pdsUrl), - carla: AtpApi.service(pdsUrl), + const agents = { + loggedout: new AtpAgent({service: pdsUrl}), + alice: new AtpAgent({service: pdsUrl}), + bob: new AtpAgent({service: pdsUrl}), + carla: new AtpAgent({service: pdsUrl}), } const users: TestUser[] = [ { @@ -125,7 +125,7 @@ async function genMockData(pdsUrl: string): Promise { declarationCid: '', handle: 'alice.test', password: 'hunter2', - api: clients.alice, + agent: agents.alice, }, { email: 'bob@test.com', @@ -133,7 +133,7 @@ async function genMockData(pdsUrl: string): Promise { declarationCid: '', handle: 'bob.test', password: 'hunter2', - api: clients.bob, + agent: agents.bob, }, { email: 'carla@test.com', @@ -141,7 +141,7 @@ async function genMockData(pdsUrl: string): Promise { declarationCid: '', handle: 'carla.test', password: 'hunter2', - api: clients.carla, + agent: agents.carla, }, ] const alice = users[0] @@ -150,18 +150,18 @@ async function genMockData(pdsUrl: string): Promise { let _i = 1 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, handle: user.handle, password: user.password, }) - user.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`) - const {data: profile} = await user.api.app.bsky.actor.getProfile({ + user.agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`) + const {data: profile} = await user.agent.api.app.bsky.actor.getProfile({ actor: user.handle, }) user.did = res.data.did 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}, { displayName: ucfirst(user.handle).slice(0, -5), @@ -172,7 +172,7 @@ async function genMockData(pdsUrl: string): Promise { // everybody follows everybody 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}, { subject: { diff --git a/package.json b/package.json index 99bcf636fa..8790a213bd 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ "e2e": "detox test --configuration ios.sim.debug --take-screenshots all" }, "dependencies": { - "@atproto/api": "^0.0.8", + "@atproto/api": "^0.1.0", "@atproto/lexicon": "^0.0.4", - "@atproto/xrpc": "^0.0.3", + "@atproto/xrpc": "^0.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4", "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/free-regular-svg-icons": "^6.1.1", @@ -43,6 +43,7 @@ "lru_map": "^0.4.1", "mobx": "^6.6.1", "mobx-react-lite": "^3.4.0", + "normalize-url": "^8.0.0", "react": "18.2.0", "react-circular-progressbar": "^2.1.0", "react-dom": "17.0.2", @@ -128,7 +129,7 @@ "node" ], "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": [ "__tests__/.*/__mocks__", diff --git a/src/App.native.tsx b/src/App.native.tsx index 750ea115d7..1977c0d289 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -14,6 +14,7 @@ import {MobileShell} from './view/shell/mobile' import {s} from './view/lib/styles' import notifee, {EventType} from '@notifee/react-native' import {segmentClient} from './lib/segmentClient' +import * as Toast from './view/com/util/Toast' const App = observer(() => { const [rootStore, setRootStore] = useState( @@ -36,6 +37,9 @@ const App = observer(() => { Linking.addEventListener('url', ({url}) => { store.nav.handleLink(url) }) + store.onSessionDropped(() => { + Toast.show('Sorry! Your session expired. Please log in again.') + }) notifee.onForegroundEvent(async ({type}: {type: EventType}) => { store.log.debug('Notifee foreground event', {type}) if (type === EventType.PRESS) { diff --git a/src/state/index.ts b/src/state/index.ts index f8243382fe..19c3af1f7f 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -1,6 +1,6 @@ import {autorun} from 'mobx' import {Platform} from 'react-native' -import {sessionClient as SessionAtpApi} from '@atproto/api' +import {AtpAgent} from '@atproto/api' import {RootStoreModel} from './models/root-store' import * as libapi from './lib/api' import * as storage from './lib/storage' @@ -19,8 +19,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) { libapi.doPolyfill() - const api = SessionAtpApi.service(serviceUri) - rootStore = new RootStoreModel(api) + rootStore = new RootStoreModel(new AtpAgent({service: serviceUri})) try { data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {} rootStore.log.debug('Initial hydrate', {hasSession: !!data.session}) @@ -28,16 +27,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) { } catch (e: any) { rootStore.log.error('Failed to load state from storage', e) } - - rootStore.session - .connect() - .then(() => { - rootStore.log.debug('Session connected') - return rootStore.fetchStateUpdate() - }) - .catch((e: any) => { - rootStore.log.warn('Failed initial connect', e) - }) + rootStore.attemptSessionResumption() // track changes & save to storage autorun(() => { @@ -47,7 +37,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) { // periodic state fetch setInterval(() => { - rootStore.fetchStateUpdate() + rootStore.updateSessionState() }, STATE_FETCH_INTERVAL) return rootStore diff --git a/src/state/lib/api.ts b/src/state/lib/api.ts index a88fae9282..22367c3c10 100644 --- a/src/state/lib/api.ts +++ b/src/state/lib/api.ts @@ -1,14 +1,4 @@ -/** - * 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 AtpAgent, {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api' import RNFS from 'react-native-fs' import {AtUri} from '../../third-party/uri' import {RootStoreModel} from '../models/root-store' @@ -20,8 +10,7 @@ import {Image} from '../../lib/images' const TIMEOUT = 10e3 // 10s export function doPolyfill() { - AtpApi.xrpc.fetch = fetchHandler - SessionAtpApi.xrpc.fetch = fetchHandler + AtpAgent.configure({fetch: fetchHandler}) } export interface ExternalEmbedDraft { @@ -31,6 +20,19 @@ export interface ExternalEmbedDraft { 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( store: RootStoreModel, text: string, diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts index acefe7fd92..e56c6a0989 100644 --- a/src/state/models/feed-view.ts +++ b/src/state/models/feed-view.ts @@ -258,6 +258,7 @@ export class FeedModel { * Nuke all data */ clear() { + this.rootStore.log.debug('FeedModel:clear') this.isLoading = false this.isRefreshing = false this.hasNewLatest = false @@ -273,6 +274,7 @@ export class FeedModel { * Load for first render */ async setup(isRefreshing = false) { + this.rootStore.log.debug('FeedModel:setup', {isRefreshing}) if (isRefreshing) { this.isRefreshing = true // set optimistically for UI } diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 3a431c6773..0cca679acf 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -29,6 +29,8 @@ export class MeModel { } clear() { + this.mainFeed.clear() + this.notifications.clear() this.did = '' this.handle = '' this.displayName = '' @@ -77,9 +79,10 @@ export class MeModel { async load() { const sess = this.rootStore.session - if (sess.hasSession && sess.data) { - this.did = sess.data.did || '' - this.handle = sess.data.handle + this.rootStore.log.debug('MeModel:load', {hasSession: sess.hasSession}) + if (sess.hasSession) { + this.did = sess.currentSession?.did || '' + this.handle = sess.currentSession?.handle || '' const profile = await this.rootStore.api.app.bsky.actor.getProfile({ actor: this.did, }) @@ -94,11 +97,6 @@ export class MeModel { this.avatar = '' } }) - this.mainFeed.clear() - this.mainFeed = new FeedModel(this.rootStore, 'home', { - algorithm: 'reverse-chronological', - }) - this.notifications = new NotificationsViewModel(this.rootStore, {}) await Promise.all([ this.mainFeed.setup().catch(e => { this.rootStore.log.error('Failed to setup main feed model', e) diff --git a/src/state/models/notifications-view.ts b/src/state/models/notifications-view.ts index 2c1c39b497..17fc71aeed 100644 --- a/src/state/models/notifications-view.ts +++ b/src/state/models/notifications-view.ts @@ -234,10 +234,26 @@ export class NotificationsViewModel { // 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 */ async setup(isRefreshing = false) { + this.rootStore.log.debug('NotificationsModel:setup', {isRefreshing}) if (isRefreshing) { this.isRefreshing = true // set optimistically for UI } @@ -299,7 +315,9 @@ export class NotificationsViewModel { async getNewMostRecent(): Promise { 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 ( !res.data.notifications[0] || old?.uri === res.data.notifications[0].uri diff --git a/src/state/models/post-thread-view.ts b/src/state/models/post-thread-view.ts index 251fd1b445..5c89082518 100644 --- a/src/state/models/post-thread-view.ts +++ b/src/state/models/post-thread-view.ts @@ -291,7 +291,7 @@ export class PostThreadViewModel { const urip = new AtUri(this.params.uri) if (!urip.host.startsWith('did:')) { try { - urip.host = await this.rootStore.resolveName(urip.host) + urip.host = await apilib.resolveName(this.rootStore, urip.host) } catch (e: any) { this.error = e.toString() } diff --git a/src/state/models/profiles-view.ts b/src/state/models/profiles-view.ts index 804491c8b4..4241e50e16 100644 --- a/src/state/models/profiles-view.ts +++ b/src/state/models/profiles-view.ts @@ -31,7 +31,9 @@ export class ProfilesViewModel { } } 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) const res = await promise this.cache.set(did, res) diff --git a/src/state/models/reposted-by-view.ts b/src/state/models/reposted-by-view.ts index 885c975781..fddb6dfc85 100644 --- a/src/state/models/reposted-by-view.ts +++ b/src/state/models/reposted-by-view.ts @@ -3,6 +3,7 @@ import {AtUri} from '../../third-party/uri' import {AppBskyFeedGetRepostedBy as GetRepostedBy} from '@atproto/api' import {RootStoreModel} from './root-store' import {cleanError} from '../../lib/strings' +import * as apilib from '../lib/api' const PAGE_SIZE = 30 @@ -93,7 +94,7 @@ export class RepostedByViewModel { const urip = new AtUri(this.params.uri) if (!urip.host.startsWith('did:')) { try { - urip.host = await this.rootStore.resolveName(urip.host) + urip.host = await apilib.resolveName(this.rootStore, urip.host) } catch (e: any) { this.error = e.toString() } diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 7866c4704d..0e2e6f39c0 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -3,10 +3,7 @@ */ import {makeAutoObservable} from 'mobx' -import { - sessionClient as SessionAtpApi, - SessionServiceClient, -} from '@atproto/api' +import {AtpAgent} from '@atproto/api' import {createContext, useContext} from 'react' import {DeviceEventEmitter, EmitterSubscription} from 'react-native' import BackgroundFetch from 'react-native-background-fetch' @@ -19,10 +16,9 @@ import {ProfilesViewModel} from './profiles-view' import {LinkMetasViewModel} from './link-metas-view' import {MeModel} from './me' import {OnboardModel} from './onboard' -import {isNetworkError} from '../../lib/errors' export class RootStoreModel { - api: SessionServiceClient + agent: AtpAgent log = new LogModel() session = new SessionModel(this) nav = new NavigationModel() @@ -32,62 +28,18 @@ export class RootStoreModel { profiles = new ProfilesViewModel(this) linkMetas = new LinkMetasViewModel(this) - constructor(api: SessionServiceClient) { - this.api = api // to keep typescript from whining - this.setAPI(api) + constructor(agent: AtpAgent) { + this.agent = agent makeAutoObservable(this, { api: false, - resolveName: false, serialize: false, hydrate: false, }) this.initBgFetch() } - setAPI(api: SessionServiceClient) { - if (this.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) - } + get api() { + return this.agent.api } 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.nav.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 { return DeviceEventEmitter.addListener('post-deleted', handler) } @@ -138,6 +150,14 @@ export class RootStoreModel { DeviceEventEmitter.emit('post-deleted', uri) } + onSessionDropped(handler: () => void): EmitterSubscription { + return DeviceEventEmitter.addListener('session-dropped', handler) + } + + emitSessionDropped() { + DeviceEventEmitter.emit('session-dropped') + } + // background fetch // = // - 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( - 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 const RootStoreContext = createContext(throwawayInst) export const RootStoreProvider = RootStoreContext.Provider diff --git a/src/state/models/session.ts b/src/state/models/session.ts index babae9c946..03fe7b14e8 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -1,24 +1,22 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import AtpApi, { - sessionClient as SessionAtpApi, - Session, +import {makeAutoObservable} from 'mobx' +import { + AtpAgent, + AtpSessionEvent, + AtpSessionData, ComAtprotoServerGetAccountsConfig as GetAccountsConfig, } from '@atproto/api' +import normalizeUrl from 'normalize-url' import {isObj, hasProp} from '../lib/type-guards' import {z} from 'zod' import {RootStoreModel} from './root-store' -import {isNetworkError} from '../../lib/errors' export type ServiceDescription = GetAccountsConfig.OutputSchema -export const sessionData = z.object({ +export const activeSession = z.object({ service: z.string(), - refreshJwt: z.string(), - accessJwt: z.string(), - handle: z.string(), did: z.string(), }) -export type SessionData = z.infer +export type ActiveSession = z.infer export const accountData = z.object({ service: z.string(), @@ -31,18 +29,20 @@ export const accountData = z.object({ }) export type AccountData = z.infer +interface AdditionalAccountData { + displayName?: string + aviUrl?: string +} + 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[] = [] - online = false - attemptingConnect = false - private _connectPromise: Promise | undefined constructor(public rootStore: RootStoreModel) { 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() { - return this.data !== null + return !!this.currentSession && !!this.rootStore.agent.session } get hasAccounts() { @@ -74,8 +88,8 @@ export class SessionModel { hydrate(v: unknown) { this.accounts = [] if (isObj(v)) { - if (hasProp(v, 'data') && sessionData.safeParse(v.data)) { - this.data = v.data as SessionData + if (hasProp(v, 'data') && activeSession.safeParse(v.data)) { + this.data = v.data as ActiveSession } if (hasProp(v, 'accounts') && Array.isArray(v.accounts)) { for (const account of v.accounts) { @@ -89,93 +103,91 @@ export class SessionModel { clear() { 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 { - if (!this.data) { - return false - } - - try { - const serviceUri = new URL(this.data.service) - const api = SessionAtpApi.service(serviceUri) - api.sessionManager.set({ - refreshJwt: this.data.refreshJwt, - accessJwt: this.data.accessJwt, - }) - this.rootStore.setAPI(api) - } catch (e: any) { - this.rootStore.log.error( - `Invalid service URL: ${this.data.service}. Resetting session.`, - e, + async attemptSessionResumption() { + const sess = this.currentSession + if (sess) { + this.rootStore.log.debug( + 'SessionModel:attemptSessionResumption found stored session', + ) + return this.resumeSession(sess) + } else { + this.rootStore.log.debug( + 'SessionModel:attemptSessionResumption has no session to resume', ) - this.clear() - return false } - return true } /** - * Upserts the current session into the accounts + * Sets the active session */ - private addSessionToAccounts() { - if (!this.data) { - return + setActiveSession(agent: AtpAgent, did: string) { + this.rootStore.log.debug('SessionModel:setActiveSession') + 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( - acc => acc.service === this.data?.service && acc.did === this.data.did, + account => account.service === service && account.did === did, ) const newAccount = { - service: this.data.service, - refreshJwt: this.data.refreshJwt, - accessJwt: this.data.accessJwt, - handle: this.data.handle, - did: this.data.did, - displayName: this.rootStore.me.displayName, - aviUrl: this.rootStore.me.avatar, + service, + did, + refreshJwt: session?.refreshJwt, + accessJwt: session?.accessJwt, + handle: session?.handle || existingAccount?.handle || '', + displayName: addedInfo + ? addedInfo.displayName + : existingAccount?.displayName || '', + aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '', } if (!existingAccount) { this.accounts.push(newAccount) } else { - this.accounts = this.accounts - .filter( - acc => - !(acc.service === this.data?.service && acc.did === this.data.did), - ) - .concat([newAccount]) + this.accounts = [ + newAccount, + ...this.accounts.filter( + account => !(account.service === service && account.did === did), + ), + ] + } + + // if the session expired, fire an event to let the user know + if (event === 'expired') { + this.rootStore.handleSessionDrop() } } /** * Clears any session tokens from the accounts; used on logout. */ - private clearSessionTokensFromAccounts() { + private clearSessionTokens() { + this.rootStore.log.debug('SessionModel:clearSessionTokens') this.accounts = this.accounts.map(acct => ({ service: acct.service, handle: acct.handle, @@ -186,59 +198,73 @@ export class SessionModel { } /** - * Fetches the current session from the service, if possible. - * Requires an existing session (.data) to be populated with access tokens. + * Fetches additional information about an account on load. */ - async connect(): Promise { - if (this._connectPromise) { - return this._connectPromise - } - this._connectPromise = this._connect() - const res = await this._connectPromise - this._connectPromise = undefined - return res - } - - private async _connect(): Promise { - 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 + private async loadAccountInfo(agent: AtpAgent, did: string) { + const res = await agent.api.app.bsky.actor + .getProfile({actor: did}) + .catch(_e => undefined) + if (res) { + return { + dispayName: res.data.displayName, + aviUrl: res.data.avatar, } } - - this.setOnline(false, false) - return false } /** * Helper to fetch the accounts config settings from an account. */ async describeService(service: string): Promise { - const api = AtpApi.service(service) - const res = await api.com.atproto.server.getAccountsConfig({}) + const agent = new AtpAgent({service}) + const res = await agent.api.com.atproto.server.getAccountsConfig({}) return res.data } + /** + * Attempt to resume a session that we still have access tokens for. + */ + async resumeSession(account: AccountData): Promise { + 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. */ @@ -251,66 +277,22 @@ export class SessionModel { identifier: string password: string }) { - const api = AtpApi.service(service) - const res = await api.com.atproto.session.create({identifier, password}) - if (res.data.accessJwt && res.data.refreshJwt) { - this.setState({ - service: service, - 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() - }) + this.rootStore.log.debug('SessionModel:login') + const agent = new AtpAgent({service}) + await agent.login({identifier, password}) + if (!agent.session) { + throw new Error('Failed to establish session') } - } - - /** - * Attempt to resume a session that we still have access tokens for. - */ - async resumeSession(account: AccountData): Promise { - if (!(account.accessJwt && account.refreshJwt && account.service)) { - return false - } - - // 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() + 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.log.debug('SessionModel:login succeeded') } async createAccount({ @@ -326,33 +308,38 @@ export class SessionModel { handle: string inviteCode?: string }) { - const api = AtpApi.service(service) - const res = await api.com.atproto.account.create({ + this.rootStore.log.debug('SessionModel:createAccount') + const agent = new AtpAgent({service}) + await agent.createAccount({ handle, password, email, inviteCode, }) - if (res.data.accessJwt && res.data.refreshJwt) { - this.setState({ - 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() - }) + if (!agent.session) { + throw new Error('Failed to establish session') } + const did = agent.session.did + const addedInfo = await this.loadAccountInfo(agent, did) + this.persistSession(service, did, 'create', agent.session, addedInfo) + agent.setPersistSessionHandler( + (evt: AtpSessionEvent, sess?: AtpSessionData) => { + this.persistSession(service, did, evt, sess) + }, + ) + this.setActiveSession(agent, did) + this.rootStore.onboard.start() + this.rootStore.log.debug('SessionModel:createAccount succeeded') } /** * Close all sessions across all accounts. */ 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) { this.rootStore.api.com.atproto.session.delete().catch((e: any) => { this.rootStore.log.warn( @@ -361,7 +348,7 @@ export class SessionModel { ) }) }*/ - this.clearSessionTokensFromAccounts() - this.rootStore.clearAll() + this.clearSessionTokens() + this.rootStore.clearAllSessionState() } } diff --git a/src/state/models/votes-view.ts b/src/state/models/votes-view.ts index fa7bce6653..3e9c3396bb 100644 --- a/src/state/models/votes-view.ts +++ b/src/state/models/votes-view.ts @@ -3,6 +3,7 @@ import {AtUri} from '../../third-party/uri' import {AppBskyFeedGetVotes as GetVotes} from '@atproto/api' import {RootStoreModel} from './root-store' import {cleanError} from '../../lib/strings' +import * as apilib from '../lib/api' const PAGE_SIZE = 30 @@ -90,7 +91,7 @@ export class VotesViewModel { const urip = new AtUri(this.params.uri) if (!urip.host.startsWith('did:')) { try { - urip.host = await this.rootStore.resolveName(urip.host) + urip.host = await apilib.resolveName(this.rootStore, urip.host) } catch (e: any) { this.error = e.toString() } diff --git a/src/view/com/login/Signin.tsx b/src/view/com/login/Signin.tsx index ea968cdf2c..27ae39736d 100644 --- a/src/view/com/login/Signin.tsx +++ b/src/view/com/login/Signin.tsx @@ -10,7 +10,7 @@ import { } from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import * as EmailValidator from 'email-validator' -import AtpApi from '@atproto/api' +import AtpAgent from '@atproto/api' import {useAnalytics} from '@segment/analytics-react-native' import {LogoTextHero} from './Logo' import {Text} from '../util/text/Text' @@ -488,8 +488,8 @@ const ForgotPasswordForm = ({ setIsProcessing(true) try { - const api = AtpApi.service(serviceUrl) - await api.com.atproto.account.requestPasswordReset({email}) + const agent = new AtpAgent({service: serviceUrl}) + await agent.api.com.atproto.account.requestPasswordReset({email}) onEmailSent() } catch (e: any) { const errMsg = e.toString() @@ -625,8 +625,11 @@ const SetNewPasswordForm = ({ setIsProcessing(true) try { - const api = AtpApi.service(serviceUrl) - await api.com.atproto.account.resetPassword({token: resetCode, password}) + const agent = new AtpAgent({service: serviceUrl}) + await agent.api.com.atproto.account.resetPassword({ + token: resetCode, + password, + }) onPasswordSet() } catch (e: any) { const errMsg = e.toString() diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index c118682c8c..6a3866abed 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -30,7 +30,7 @@ export function UserAvatar({ avatar?: string | null onSelectNewAvatar?: (img: PickedImage) => void }) { - const initials = getInitials(displayName || handle) + const initials = getInitials(displayName || handle || '') const pal = usePalette('default') const renderSvg = (svgSize: number, svgInitials: string) => ( diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 62745827c7..f449d61948 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -1,11 +1,6 @@ import React from 'react' import {observer} from 'mobx-react-lite' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' +import {StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {UserAvatar} from './UserAvatar' import {Text} from './text/Text' @@ -40,11 +35,6 @@ export const ViewHeader = observer(function ViewHeader({ const onPressSearch = () => { store.nav.navigate('/search') } - const onPressReconnect = () => { - store.session.connect().catch(e => { - store.log.warn('Failed to reconnect to server', e) - }) - } if (typeof canGoBack === 'undefined') { canGoBack = store.nav.tab.canGoBack } @@ -89,25 +79,6 @@ export const ViewHeader = observer(function ViewHeader({ style={styles.btn}> - {!store.session.online ? ( - - {store.session.attemptingConnect ? ( - - ) : ( - <> - - - - )} - - ) : undefined} ) }) diff --git a/yarn.lock b/yarn.lock index 4bdbbc011c..e62b042a44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19,10 +19,10 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@atproto/api@^0.0.8": - version "0.0.8" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.0.8.tgz#ff01bb193cd1dad422916572ff595a625fa3cc06" - integrity sha512-qEnrzy8vKnt3yEjRCmKUnTvW6GKPB9pB5PP1+qIfUnQ7Sqrb793LtaSXPk9ZnrvtL3fsxYnfUY+7KB2X4osSyA== +"@atproto/api@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.1.0.tgz#0677cdde0a8b943b904dbc3f7f7357b329eca48a" + integrity sha512-+vKAEEgh3GgLfK/QZGAoQKesmhYcCsuiiEzr1miCQAuf7zfTsbX27zHERIuEVT+TqTH98MNbd3uyzF/NAbYMwQ== dependencies: "@atproto/xrpc" "*" typed-emitter "^2.1.0" @@ -180,7 +180,7 @@ mime-types "^2.1.35" zod "^3.14.2" -"@atproto/xrpc@*", "@atproto/xrpc@^0.0.3": +"@atproto/xrpc@*": version "0.0.3" resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.0.3.tgz#510028753d51dffd754ee4f96897b74bcba50bda" integrity sha512-dOxtUqUfXOalPhtc1jyIvzCasd+iXwt/Sp+QIAy5qOTCxF9IvXuJ9bNFY49NUPEWlagaRtL8glQhbR307GHcuA== @@ -188,6 +188,14 @@ "@atproto/lexicon" "*" 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": version "7.18.6" 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" 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: version "2.0.2" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"