> {
+ const result = await NativeModule.verifyJwt(
+ JSON.stringify(this.publicJwk),
+ token,
+ JSON.stringify(options),
+ )
+ return result
+ // return result as VerifyResult
+ }
+
+ static async fromImportable(
+ input: Importable,
+ kid?: string,
+ ): Promise {
+ if (typeof input === 'string') {
+ // PKCS8
+ if (input.startsWith('-----')) {
+ return this.fromPKCS8(input, kid)
+ }
+
+ // Jwk (string)
+ if (input.startsWith('{')) {
+ return this.fromJWK(input, kid)
+ }
+
+ throw new TypeError('Invalid input')
+ }
+
+ if (typeof input === 'object') {
+ // Jwk
+ if ('kty' in input || 'alg' in input) {
+ return this.fromJWK(input, kid)
+ }
+
+ // KeyLike
+ return this.fromJWK(await exportJWK(input), kid)
+ }
+
+ throw new TypeError('Invalid input')
+ }
+
+ static async fromPKCS8(pem: string, kid?: string): Promise {
+ const keyLike = await importPKCS8(pem, '', {extractable: true})
+ return this.fromJWK(await exportJWK(keyLike), kid)
+ }
+
+ static async fromJWK(
+ input: string | Record,
+ inputKid?: string,
+ ): Promise {
+ const jwk = jwkSchema.parse(
+ typeof input === 'string' ? JSON.parse(input) : input,
+ )
+
+ const kid = either(jwk.kid, inputKid)
+ const alg = jwk.alg
+ const use = jwk.use || 'sig'
+
+ return new JoseKey({...jwk, kid, alg, use})
+ }
+}
diff --git a/modules/expo-bluesky-oauth-client/src/native-types.ts b/modules/expo-bluesky-oauth-client/src/native-types.ts
new file mode 100644
index 0000000000..2ac1d7fa40
--- /dev/null
+++ b/modules/expo-bluesky-oauth-client/src/native-types.ts
@@ -0,0 +1,20 @@
+export interface CryptoKey {
+ algorithm: {
+ name: 'ECDSA'
+ namedCurve: 'P-256'
+ }
+ extractable: boolean
+ type: 'public' | 'private'
+ usages: ('sign' | 'verify')[]
+}
+
+export interface NativeJWKKey {
+ crv: 'P-256'
+ ext: boolean
+ kty: 'EC'
+ x: string
+ y: string
+ use: 'sig'
+ alg: 'ES256'
+ kid: string
+}
diff --git a/modules/expo-bluesky-oauth-client/src/rn-crypto-key.ts b/modules/expo-bluesky-oauth-client/src/rn-crypto-key.ts
new file mode 100644
index 0000000000..d5e4360889
--- /dev/null
+++ b/modules/expo-bluesky-oauth-client/src/rn-crypto-key.ts
@@ -0,0 +1,86 @@
+import {requireNativeModule} from 'expo-modules-core'
+import {Jwk, jwkSchema} from '@atproto/jwk'
+
+import {JoseKey} from './jose-key'
+import {CryptoKey, NativeJWKKey} from './native-types'
+
+const NativeModule = requireNativeModule('ExpoBlueskyOAuthClient')
+
+interface CryptoKeyPair {
+ privateKey: CryptoKey
+ publicKey: CryptoKey
+}
+
+interface NativeJWKKeyPair {
+ privateKey: NativeJWKKey
+ publicKey: NativeJWKKey
+}
+
+export class RnCryptoKey extends JoseKey {
+ static async generate(
+ kid: string | undefined,
+ _: string[] = ['ES256'],
+ __ = false,
+ ) {
+ const {privateKey, publicKey} = await NativeModule.generateKeyPair(kid)
+
+ const nativeKeyPair = {
+ privateKey: JSON.parse(privateKey),
+ publicKey: JSON.parse(publicKey),
+ }
+
+ return this.fromKeypair(nativeKeyPair.privateKey.kid, nativeKeyPair)
+ }
+
+ static fromKeypair(kid: string, cryptoKeyPair: NativeJWKKeyPair) {
+ const use = cryptoKeyPair.privateKey.use ?? 'sig'
+ const alg = cryptoKeyPair.privateKey.alg ?? 'ES256'
+
+ if (use !== 'sig') {
+ throw new TypeError('Unsupported JWK use')
+ }
+
+ const webCryptoKeyPair: CryptoKeyPair = {
+ privateKey: {
+ algorithm: {
+ name: 'ECDSA',
+ namedCurve: 'P-256',
+ },
+ extractable: true,
+ type: 'private',
+ usages: ['sign'],
+ },
+ publicKey: {
+ algorithm: {
+ name: 'ECDSA',
+ namedCurve: 'P-256',
+ },
+ extractable: true,
+ type: 'public',
+ usages: ['verify'],
+ },
+ }
+
+ return new RnCryptoKey(
+ jwkSchema.parse({...cryptoKeyPair.privateKey, use, kid, alg}),
+ webCryptoKeyPair,
+ )
+ }
+
+ constructor(jwk: Jwk, readonly cryptoKeyPair: CryptoKeyPair) {
+ super(jwk)
+ }
+
+ get isPrivate() {
+ return true
+ }
+
+ get privateJwk(): Jwk | undefined {
+ if (super.isPrivate) return this.jwk
+ throw new Error('Private key is not exportable.')
+ }
+
+ protected async getKey() {
+ return this.cryptoKeyPair.privateKey
+ }
+}
diff --git a/modules/expo-bluesky-oauth-client/src/rn-crypto-key.web.ts b/modules/expo-bluesky-oauth-client/src/rn-crypto-key.web.ts
new file mode 100644
index 0000000000..b9fbd05758
--- /dev/null
+++ b/modules/expo-bluesky-oauth-client/src/rn-crypto-key.web.ts
@@ -0,0 +1,78 @@
+import {Jwk, jwkSchema} from '@atproto/jwk'
+import {JoseKey} from '@atproto/jwk-jose'
+
+import {CryptoKey} from './native-types'
+import {generateKeyPair, isSignatureKeyPair} from './util.web'
+
+// @ts-ignore web only, stops warnings for crypto being missing
+const crypto = global.crypto
+
+// Global has this, but this stops the warnings
+interface CryptoKeyPair {
+ privateKey: CryptoKey
+ publicKey: CryptoKey
+}
+
+export class RNCryptoKey extends JoseKey {
+ static async generate(
+ kid: string = crypto.randomUUID(),
+ allowedAlgos: string[] = ['ES256'],
+ exportable = false,
+ ) {
+ const cryptoKeyPair: CryptoKeyPair = await generateKeyPair(
+ allowedAlgos,
+ exportable,
+ )
+ return this.fromKeypair(kid, cryptoKeyPair)
+ }
+
+ static async fromKeypair(
+ kid: string,
+ cryptoKeyPair: CryptoKeyPair,
+ ): Promise {
+ if (!isSignatureKeyPair(cryptoKeyPair)) {
+ throw new TypeError('CryptoKeyPair must be compatible with sign/verify')
+ }
+
+ // https://datatracker.ietf.org/doc/html/rfc7517
+ // > The "use" and "key_ops" JWK members SHOULD NOT be used together; [...]
+ // > Applications should specify which of these members they use.
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const {key_ops: _, ...jwk} = await crypto.subtle.exportKey(
+ 'jwk',
+ cryptoKeyPair.privateKey.extractable
+ ? cryptoKeyPair.privateKey
+ : cryptoKeyPair.publicKey,
+ )
+
+ const use = jwk.use ?? 'sig'
+ const alg = jwk.alg ?? 'ES256'
+
+ if (use !== 'sig') {
+ throw new TypeError('Unsupported JWK use')
+ }
+
+ return new RNCryptoKey(
+ jwkSchema.parse({...jwk, use, kid, alg}),
+ cryptoKeyPair,
+ )
+ }
+
+ constructor(jwk: Jwk, readonly cryptoKeyPair: CryptoKeyPair) {
+ super(jwk)
+ }
+
+ get isPrivate() {
+ return true
+ }
+
+ get privateJwk(): Jwk | undefined {
+ if (super.isPrivate) return this.jwk
+ throw new Error('Private key is not exportable.')
+ }
+
+ protected async getKey() {
+ return this.cryptoKeyPair.privateKey
+ }
+}
diff --git a/modules/expo-bluesky-oauth-client/src/rn-oauth-client-factory.ts b/modules/expo-bluesky-oauth-client/src/rn-oauth-client-factory.ts
new file mode 100644
index 0000000000..3e6f0c37cc
--- /dev/null
+++ b/modules/expo-bluesky-oauth-client/src/rn-oauth-client-factory.ts
@@ -0,0 +1,153 @@
+import {Fetch} from '@atproto/fetch'
+import {UniversalIdentityResolver} from '@atproto/identity-resolver'
+import {
+ OAuthAuthorizeOptions,
+ OAuthClientFactory,
+ OAuthResponseMode,
+ OAuthResponseType,
+ Session,
+} from '@atproto/oauth-client'
+import {OAuthClientMetadata} from '@atproto/oauth-client-metadata'
+import IsomorphicOAuthServerMetadataResolver from '@atproto/oauth-server-metadata-resolver'
+
+import {CryptoSubtle} from './crypto-subtle'
+import {
+ BrowserOAuthDatabase,
+ DatabaseStore,
+ PopupStateData,
+} from './rn-oauth-database'
+
+export type BrowserOauthClientFactoryOptions = {
+ responseMode?: OAuthResponseMode
+ responseType?: OAuthResponseType
+ clientMetadata: OAuthClientMetadata
+ fetch?: Fetch
+ crypto?: Crypto
+}
+
+const POPUP_KEY_PREFIX = '@@oauth-popup-callback:'
+
+export class BrowserOAuthClientFactory extends OAuthClientFactory {
+ readonly popupStore: DatabaseStore
+ readonly sessionStore: DatabaseStore
+
+ constructor({
+ clientMetadata,
+ // "fragment" is safer as it is not sent to the server
+ responseMode = 'fragment',
+ responseType,
+ crypto = globalThis.crypto,
+ fetch = globalThis.fetch,
+ }: BrowserOauthClientFactoryOptions) {
+ const database = new BrowserOAuthDatabase()
+
+ super({
+ clientMetadata,
+ responseMode,
+ responseType,
+ fetch,
+ cryptoImplementation: new CryptoSubtle(crypto),
+ sessionStore: database.getSessionStore(),
+ stateStore: database.getStateStore(),
+ metadataResolver: new IsomorphicOAuthServerMetadataResolver({
+ fetch,
+ cache: database.getMetadataCache(),
+ }),
+ identityResolver: UniversalIdentityResolver.from({
+ fetch,
+ didCache: database.getDidCache(),
+ handleCache: database.getHandleCache(),
+ }),
+ dpopNonceCache: database.getDpopNonceCache(),
+ })
+
+ this.sessionStore = database.getSessionStore()
+ this.popupStore = database.getPopupStore()
+ }
+
+ async restoreAll() {
+ const sessionIds = await this.sessionStore.getKeys()
+ return Object.fromEntries(
+ await Promise.all(
+ sessionIds.map(
+ async sessionId =>
+ [sessionId, await this.restore(sessionId, false)] as const,
+ ),
+ ),
+ )
+ }
+
+ async init(sessionId?: string, forceRefresh = false) {
+ const signInResult = await this.signInCallback()
+ if (signInResult) {
+ return signInResult
+ } else if (sessionId) {
+ const client = await this.restore(sessionId, forceRefresh)
+ return {client}
+ } else {
+ // TODO: we could restore any session from the store ?
+ }
+ }
+
+ async signIn(input: string, options?: OAuthAuthorizeOptions) {
+ return await this.authorize(input, options)
+ }
+
+ async signInCallback() {
+ const redirectUri = new URL(this.clientMetadata.redirect_uris[0])
+ if (location.pathname !== redirectUri.pathname) return null
+
+ const params =
+ this.responseMode === 'query'
+ ? new URLSearchParams(location.search)
+ : new URLSearchParams(location.hash.slice(1))
+
+ // Only if the query string contains oauth callback params
+ if (
+ !params.has('iss') ||
+ !params.has('state') ||
+ !(params.has('code') || params.has('error'))
+ ) {
+ return null
+ }
+
+ // Replace the current history entry without the query string (this will
+ // prevent this 'if' branch to run again if the user refreshes the page)
+ history.replaceState(null, '', location.pathname)
+
+ return this.callback(params)
+ .then(async result => {
+ if (result.state?.startsWith(POPUP_KEY_PREFIX)) {
+ const stateKey = result.state.slice(POPUP_KEY_PREFIX.length)
+
+ await this.popupStore.set(stateKey, {
+ status: 'fulfilled',
+ value: result.client.sessionId,
+ })
+
+ window.close() // continued in signInPopup
+ throw new Error('Login complete, please close the popup window.')
+ }
+
+ return result
+ })
+ .catch(async err => {
+ // TODO: Throw a proper error from parent class to actually detect
+ // oauth authorization errors
+ const state = typeof (err as any)?.state
+ if (typeof state === 'string' && state?.startsWith(POPUP_KEY_PREFIX)) {
+ const stateKey = state.slice(POPUP_KEY_PREFIX.length)
+
+ await this.popupStore.set(stateKey, {
+ status: 'rejected',
+ reason: err,
+ })
+
+ window.close() // continued in signInPopup
+ throw new Error('Login complete, please close the popup window.')
+ }
+
+ throw err
+ })
+ }
+}
diff --git a/modules/expo-bluesky-oauth-client/src/rn-oauth-database.native.ts b/modules/expo-bluesky-oauth-client/src/rn-oauth-database.native.ts
new file mode 100644
index 0000000000..8a10341910
--- /dev/null
+++ b/modules/expo-bluesky-oauth-client/src/rn-oauth-database.native.ts
@@ -0,0 +1,214 @@
+import {GenericStore, Value} from '@atproto/caching'
+import {DidDocument} from '@atproto/did'
+import {ResolvedHandle} from '@atproto/handle-resolver'
+import {Key} from '@atproto/jwk'
+import {WebcryptoKey} from '@atproto/jwk-webcrypto'
+import {InternalStateData, Session, TokenSet} from '@atproto/oauth-client'
+import {OAuthServerMetadata} from '@atproto/oauth-server-metadata'
+import Storage from '@react-native-async-storage/async-storage'
+
+type Item = {
+ value: string
+ expiresAt: null | Date
+}
+
+type EncodedKey = {
+ keyId: string
+ keyPair: CryptoKeyPair
+}
+
+function encodeKey(key: Key): EncodedKey {
+ if (!(key instanceof WebcryptoKey) || !key.kid) {
+ throw new Error('Invalid key object')
+ }
+ return {
+ keyId: key.kid,
+ keyPair: key.cryptoKeyPair,
+ }
+}
+
+async function decodeKey(encoded: EncodedKey): Promise {
+ return WebcryptoKey.fromKeypair(encoded.keyId, encoded.keyPair)
+}
+
+export type Schema = {
+ state: Item<{
+ dpopKey: EncodedKey
+
+ iss: string
+ nonce: string
+ verifier?: string
+ appState?: string
+ }>
+ session: Item<{
+ dpopKey: EncodedKey
+
+ tokenSet: TokenSet
+ }>
+
+ didCache: Item
+ dpopNonceCache: Item
+ handleCache: Item
+ metadataCache: Item
+}
+
+export type DatabaseStore = GenericStore & {
+ getKeys: () => Promise
+}
+
+const STORES = [
+ 'state',
+ 'session',
+
+ 'didCache',
+ 'dpopNonceCache',
+ 'handleCache',
+ 'metadataCache',
+] as const
+
+export class BrowserOAuthDatabase {
+ async delete(key: string) {
+ await Storage.removeItem(key)
+ }
+
+ protected createStore(
+ dbName: N,
+ {
+ encode,
+ decode,
+ maxAge,
+ }: {
+ encode: (value: V) => Schema[N]['value'] | PromiseLike
+ decode: (encoded: Schema[N]['value']) => V | PromiseLike
+ maxAge?: number
+ },
+ ): DatabaseStore {
+ return {
+ get: async key => {
+ const itemJson = await Storage.getItem(`${dbName}.${key}`)
+ if (itemJson == null) return undefined
+
+ const item = JSON.parse(itemJson) as Schema[N]
+
+ // Too old, proactively delete
+ if (item.expiresAt != null && item.expiresAt < new Date()) {
+ await this.delete(`${dbName}.${key}`)
+ return undefined
+ }
+
+ // Item found and valid. Decode
+ return decode(item.value)
+ },
+
+ getKeys: async () => {
+ const keys = await Storage.getAllKeys()
+ return keys.filter(key => key.startsWith(`${dbName}.`)) as string[]
+ },
+
+ set: async (key, value) => {
+ const item = {
+ value: await encode(value),
+ expiresAt: maxAge == null ? null : new Date(Date.now() + maxAge),
+ } as Schema[N]
+
+ await Storage.setItem(`${dbName}.${key}`, JSON.stringify(item))
+ },
+
+ del: async key => {
+ await this.delete(`${dbName}.${key}`)
+ },
+ }
+ }
+
+ getSessionStore(): DatabaseStore {
+ return this.createStore('session', {
+ encode: ({dpopKey, ...session}) => ({
+ ...session,
+ dpopKey: encodeKey(dpopKey),
+ }),
+ decode: async ({dpopKey, ...encoded}) => ({
+ ...encoded,
+ dpopKey: await decodeKey(dpopKey),
+ }),
+ })
+ }
+
+ getStateStore(): DatabaseStore {
+ return this.createStore('state', {
+ encode: ({dpopKey, ...session}) => ({
+ ...session,
+ dpopKey: encodeKey(dpopKey),
+ }),
+ decode: async ({dpopKey, ...encoded}) => ({
+ ...encoded,
+ dpopKey: await decodeKey(dpopKey),
+ }),
+ })
+ }
+
+ getDpopNonceCache(): undefined | DatabaseStore {
+ return this.createStore('dpopNonceCache', {
+ // No time limit. It is better to try with a potentially outdated nonce
+ // and potentially succeed rather than make requests without a nonce and
+ // 100% fail.
+ encode: value => value,
+ decode: encoded => encoded,
+ })
+ }
+
+ getDidCache(): undefined | DatabaseStore {
+ return this.createStore('didCache', {
+ maxAge: 60e3,
+ encode: value => value,
+ decode: encoded => encoded,
+ })
+ }
+
+ getHandleCache(): undefined | DatabaseStore {
+ return this.createStore('handleCache', {
+ maxAge: 60e3,
+ encode: value => value,
+ decode: encoded => encoded,
+ })
+ }
+
+ getMetadataCache(): undefined | DatabaseStore {
+ return this.createStore('metadataCache', {
+ maxAge: 60e3,
+ encode: value => value,
+ decode: encoded => encoded,
+ })
+ }
+
+ async cleanup() {
+ await Promise.all(
+ STORES.map(
+ async storeName =>
+ [
+ storeName,
+ await tx
+ .objectStore(storeName)
+ .index('expiresAt')
+ .getAllKeys(query),
+ ] as const,
+ ),
+ )
+
+ const storesWithInvalidKeys = res.filter(r => r[1].length > 0)
+
+ await db.transaction(
+ storesWithInvalidKeys.map(r => r[0]),
+ 'readwrite',
+ tx =>
+ Promise.all(
+ storesWithInvalidKeys.map(async ([name, keys]) =>
+ tx.objectStore(name).delete(keys),
+ ),
+ ),
+ )
+ }
+
+ async [Symbol.asyncDispose]() {
+ await this.cleanup()
+ }
+}
diff --git a/modules/expo-bluesky-oauth-client/src/rn-oauth-database.ts b/modules/expo-bluesky-oauth-client/src/rn-oauth-database.ts
new file mode 100644
index 0000000000..7b388dfc71
--- /dev/null
+++ b/modules/expo-bluesky-oauth-client/src/rn-oauth-database.ts
@@ -0,0 +1,269 @@
+import {GenericStore, Value} from '@atproto/caching'
+import {DidDocument} from '@atproto/did'
+import {ResolvedHandle} from '@atproto/handle-resolver'
+import {DB, DBObjectStore} from '@atproto/indexed-db'
+import {Key} from '@atproto/jwk'
+import {WebcryptoKey} from '@atproto/jwk-webcrypto'
+import {InternalStateData, Session, TokenSet} from '@atproto/oauth-client'
+import {OAuthServerMetadata} from '@atproto/oauth-server-metadata'
+
+type Item = {
+ value: V
+ expiresAt: null | Date
+}
+
+type EncodedKey = {
+ keyId: string
+ keyPair: CryptoKeyPair
+}
+
+function encodeKey(key: Key): EncodedKey {
+ if (!(key instanceof WebcryptoKey) || !key.kid) {
+ throw new Error('Invalid key object')
+ }
+ return {
+ keyId: key.kid,
+ keyPair: key.cryptoKeyPair,
+ }
+}
+
+async function decodeKey(encoded: EncodedKey): Promise {
+ return WebcryptoKey.fromKeypair(encoded.keyId, encoded.keyPair)
+}
+
+export type PopupStateData =
+ | PromiseRejectedResult
+ | PromiseFulfilledResult
+
+export type Schema = {
+ popup: Item
+ state: Item<{
+ dpopKey: EncodedKey
+
+ iss: string
+ nonce: string
+ verifier?: string
+ appState?: string
+ }>
+ session: Item<{
+ dpopKey: EncodedKey
+
+ tokenSet: TokenSet
+ }>
+
+ didCache: Item
+ dpopNonceCache: Item
+ handleCache: Item
+ metadataCache: Item
+}
+
+export type DatabaseStore = GenericStore & {
+ getKeys: () => Promise
+}
+
+const STORES = [
+ 'popup',
+ 'state',
+ 'session',
+
+ 'didCache',
+ 'dpopNonceCache',
+ 'handleCache',
+ 'metadataCache',
+] as const
+
+export class BrowserOAuthDatabase {
+ #dbPromise = DB.open(
+ '@atproto-oauth-client',
+ [
+ db => {
+ for (const name of STORES) {
+ const store = db.createObjectStore(name)
+ store.createIndex('expiresAt', 'expiresAt', {unique: false})
+ }
+ },
+ ],
+ {durability: 'strict'},
+ )
+
+ protected async run(
+ storeName: N,
+ mode: 'readonly' | 'readwrite',
+ fn: (s: DBObjectStore) => R | Promise,
+ ): Promise {
+ const db = await this.#dbPromise
+ return await db.transaction([storeName], mode, tx =>
+ fn(tx.objectStore(storeName)),
+ )
+ }
+
+ protected createStore(
+ name: N,
+ {
+ encode,
+ decode,
+ maxAge,
+ }: {
+ encode: (value: V) => Schema[N]['value'] | PromiseLike
+ decode: (encoded: Schema[N]['value']) => V | PromiseLike
+ maxAge?: number
+ },
+ ): DatabaseStore {
+ return {
+ get: async key => {
+ // Find item in store
+ const item = await this.run(name, 'readonly', dbStore => {
+ return dbStore.get(key)
+ })
+
+ // Not found
+ if (item === undefined) return undefined
+
+ // Too old, proactively delete
+ if (item.expiresAt != null && item.expiresAt < new Date()) {
+ await this.run(name, 'readwrite', dbStore => {
+ return dbStore.delete(key)
+ })
+ return undefined
+ }
+
+ // Item found and valid. Decode
+ return decode(item.value)
+ },
+
+ getKeys: async () => {
+ const keys = await this.run(name, 'readonly', dbStore => {
+ return dbStore.getAllKeys()
+ })
+ return keys.filter(key => typeof key === 'string') as string[]
+ },
+
+ set: async (key, value) => {
+ // Create encoded item record
+ const item = {
+ value: await encode(value),
+ expiresAt: maxAge == null ? null : new Date(Date.now() + maxAge),
+ } as Schema[N]
+
+ // Store item record
+ await this.run(name, 'readwrite', dbStore => {
+ return dbStore.put(item, key)
+ })
+ },
+
+ del: async key => {
+ // Delete
+ await this.run(name, 'readwrite', dbStore => {
+ return dbStore.delete(key)
+ })
+ },
+ }
+ }
+
+ getSessionStore(): DatabaseStore {
+ return this.createStore('session', {
+ encode: ({dpopKey, ...session}) => ({
+ ...session,
+ dpopKey: encodeKey(dpopKey),
+ }),
+ decode: async ({dpopKey, ...encoded}) => ({
+ ...encoded,
+ dpopKey: await decodeKey(dpopKey),
+ }),
+ })
+ }
+
+ getStateStore(): DatabaseStore {
+ return this.createStore('state', {
+ encode: ({dpopKey, ...session}) => ({
+ ...session,
+ dpopKey: encodeKey(dpopKey),
+ }),
+ decode: async ({dpopKey, ...encoded}) => ({
+ ...encoded,
+ dpopKey: await decodeKey(dpopKey),
+ }),
+ })
+ }
+
+ getPopupStore(): DatabaseStore {
+ return this.createStore('popup', {
+ encode: value => value,
+ decode: encoded => encoded,
+ })
+ }
+
+ getDpopNonceCache(): undefined | DatabaseStore {
+ return this.createStore('dpopNonceCache', {
+ // No time limit. It is better to try with a potentially outdated nonce
+ // and potentially succeed rather than make requests without a nonce and
+ // 100% fail.
+ encode: value => value,
+ decode: encoded => encoded,
+ })
+ }
+
+ getDidCache(): undefined | DatabaseStore {
+ return this.createStore('didCache', {
+ maxAge: 60e3,
+ encode: value => value,
+ decode: encoded => encoded,
+ })
+ }
+
+ getHandleCache(): undefined | DatabaseStore {
+ return this.createStore('handleCache', {
+ maxAge: 60e3,
+ encode: value => value,
+ decode: encoded => encoded,
+ })
+ }
+
+ getMetadataCache(): undefined | DatabaseStore {
+ return this.createStore('metadataCache', {
+ maxAge: 60e3,
+ encode: value => value,
+ decode: encoded => encoded,
+ })
+ }
+
+ async cleanup() {
+ const db = await this.#dbPromise
+ const query = IDBKeyRange.lowerBound(new Date())
+ const res = await db.transaction(STORES, 'readonly', tx =>
+ Promise.all(
+ STORES.map(
+ async storeName =>
+ [
+ storeName,
+ await tx
+ .objectStore(storeName)
+ .index('expiresAt')
+ .getAllKeys(query),
+ ] as const,
+ ),
+ ),
+ )
+
+ const storesWithInvalidKeys = res.filter(r => r[1].length > 0)
+
+ await db.transaction(
+ storesWithInvalidKeys.map(r => r[0]),
+ 'readwrite',
+ tx =>
+ Promise.all(
+ storesWithInvalidKeys.map(async ([name, keys]) =>
+ tx.objectStore(name).delete(keys),
+ ),
+ ),
+ )
+ }
+
+ async [Symbol.asyncDispose]() {
+ // TODO: call cleanup at a constant interval ?
+ await this.cleanup()
+
+ const db = await this.#dbPromise
+ await (db[Symbol.asyncDispose] || db[Symbol.dispose]).call(db)
+ }
+}
diff --git a/modules/expo-bluesky-oauth-client/src/store.ts b/modules/expo-bluesky-oauth-client/src/store.ts
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/modules/expo-bluesky-oauth-client/src/store.web.ts b/modules/expo-bluesky-oauth-client/src/store.web.ts
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/modules/expo-bluesky-oauth-client/src/util.web.ts b/modules/expo-bluesky-oauth-client/src/util.web.ts
new file mode 100644
index 0000000000..654365aae5
--- /dev/null
+++ b/modules/expo-bluesky-oauth-client/src/util.web.ts
@@ -0,0 +1,41 @@
+// @ts-ignore web only, this silences errors throughout the whole file for crypto being missing
+const crypto = global.crypto
+
+export async function generateKeyPair(algs: string[], extractable = false) {
+ const errors: unknown[] = []
+ try {
+ return await crypto.subtle.generateKey(
+ {
+ name: 'ECDSA',
+ namedCurve: `P-256`,
+ },
+ extractable,
+ ['sign', 'verify'],
+ )
+ } catch (err) {
+ errors.push(err)
+ }
+
+ console.log(errors)
+ throw new AggregateError(errors, 'Failed to generate keypair')
+}
+
+export function isSignatureKeyPair(
+ v: unknown,
+ extractable?: boolean,
+): v is CryptoKeyPair {
+ return (
+ typeof v === 'object' &&
+ v !== null &&
+ 'privateKey' in v &&
+ v.privateKey instanceof CryptoKey &&
+ v.privateKey.type === 'private' &&
+ (extractable == null || v.privateKey.extractable === extractable) &&
+ v.privateKey.usages.includes('sign') &&
+ 'publicKey' in v &&
+ v.publicKey instanceof CryptoKey &&
+ v.publicKey.type === 'public' &&
+ v.publicKey.extractable === true &&
+ v.publicKey.usages.includes('verify')
+ )
+}
diff --git a/package.json b/package.json
index e84e9b8146..d1d0c54c07 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
- "version": "1.76.0",
+ "version": "1.77.0",
"private": true,
"engines": {
"node": ">=18"
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
new file mode 100644
index 0000000000..c755ad437e
--- /dev/null
+++ b/src/lib/statsig/gates.ts
@@ -0,0 +1,8 @@
+export type Gate =
+ // Keep this alphabetic please.
+ | 'autoexpand_suggestions_on_profile_follow'
+ | 'disable_min_shell_on_foregrounding'
+ | 'disable_poll_on_discover'
+ | 'new_search'
+ | 'show_follow_back_label'
+ | 'start_session_with_following'
diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx
index c164616217..159438647a 100644
--- a/src/lib/statsig/statsig.tsx
+++ b/src/lib/statsig/statsig.tsx
@@ -9,14 +9,21 @@ import {
} from 'statsig-react-native-expo'
import {logger} from '#/logger'
+import {IS_TESTFLIGHT} from 'lib/app-info'
import {useSession} from '../../state/session'
import {LogEvents} from './events'
+import {Gate} from './gates'
export type {LogEvents}
const statsigOptions = {
environment: {
- tier: process.env.NODE_ENV === 'development' ? 'development' : 'production',
+ tier:
+ process.env.NODE_ENV === 'development'
+ ? 'development'
+ : IS_TESTFLIGHT
+ ? 'staging'
+ : 'production',
},
// Don't block on waiting for network. The fetched config will kick in on next load.
// This ensures the UI is always consistent and doesn't update mid-session.
@@ -69,7 +76,7 @@ export function logEvent(
}
}
-export function useGate(gateName: string) {
+export function useGate(gateName: Gate): boolean {
const {isLoading, value} = useStatsigGate(gateName)
if (isLoading) {
// This should not happen because of waitForInitialization={true}.
diff --git a/src/state/queries/search-posts.ts b/src/state/queries/search-posts.ts
index 9bf3c0f9ec..ef8b083584 100644
--- a/src/state/queries/search-posts.ts
+++ b/src/state/queries/search-posts.ts
@@ -10,12 +10,19 @@ import {getAgent} from '#/state/session'
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
const searchPostsQueryKeyRoot = 'search-posts'
-const searchPostsQueryKey = ({query}: {query: string}) => [
+const searchPostsQueryKey = ({query, sort}: {query: string; sort?: string}) => [
searchPostsQueryKeyRoot,
query,
+ sort,
]
-export function useSearchPostsQuery({query}: {query: string}) {
+export function useSearchPostsQuery({
+ query,
+ sort,
+}: {
+ query: string
+ sort?: 'top' | 'latest'
+}) {
return useInfiniteQuery<
AppBskyFeedSearchPosts.OutputSchema,
Error,
@@ -23,14 +30,20 @@ export function useSearchPostsQuery({query}: {query: string}) {
QueryKey,
string | undefined
>({
- queryKey: searchPostsQueryKey({query}),
+ queryKey: searchPostsQueryKey({query, sort}),
queryFn: async ({pageParam}) => {
- const res = await getAgent().app.bsky.feed.searchPosts({
- q: query,
- limit: 25,
- cursor: pageParam,
- })
- return res.data
+ // waiting on new APIs
+ switch (sort) {
+ // case 'top':
+ // case 'latest':
+ default:
+ const res = await getAgent().app.bsky.feed.searchPosts({
+ q: query,
+ limit: 25,
+ cursor: pageParam,
+ })
+ return res.data
+ }
},
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 39bdac669c..a97f2d2986 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -19,6 +19,7 @@ import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
+import {RnCryptoKey} from '../../../modules/expo-bluesky-oauth-client'
import {HomeLoggedOutCTA} from '../com/auth/HomeLoggedOutCTA'
import {HomeHeader} from '../com/home/HomeHeader'
@@ -51,6 +52,18 @@ function HomeScreenReady({
preferences: UsePreferencesQueryResponse
pinnedFeedInfos: FeedSourceInfo[]
}) {
+ React.useEffect(() => {
+ ;(async () => {
+ const key = await RnCryptoKey.generate(undefined, ['ES256'], false)
+ console.log('public', key.publicJwk)
+ const jwt = await key.createJwt(
+ {alg: 'ES256', kid: key.kid},
+ {sub: 'test'},
+ )
+ console.log(jwt)
+ })()
+ }, [])
+
const allFeeds = React.useMemo(() => {
const feeds: FeedDescriptor[] = []
feeds.push('home')
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index c0f4cf1950..0f24252ce6 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -22,6 +22,7 @@ import {HITSLOP_10} from '#/lib/constants'
import {usePalette} from '#/lib/hooks/usePalette'
import {MagnifyingGlassIcon} from '#/lib/icons'
import {NavigationProp} from '#/lib/routes/types'
+import {useGate} from '#/lib/statsig/statsig'
import {augmentSearchQuery} from '#/lib/strings/helpers'
import {s} from '#/lib/styles'
import {logger} from '#/logger'
@@ -191,7 +192,13 @@ type SearchResultSlice =
key: string
}
-function SearchScreenPostResults({query}: {query: string}) {
+function SearchScreenPostResults({
+ query,
+ sort,
+}: {
+ query: string
+ sort?: 'top' | 'latest'
+}) {
const {_} = useLingui()
const {currentAccount} = useSession()
const [isPTR, setIsPTR] = React.useState(false)
@@ -209,7 +216,7 @@ function SearchScreenPostResults({query}: {query: string}) {
fetchNextPage,
isFetchingNextPage,
hasNextPage,
- } = useSearchPostsQuery({query: augmentedQuery})
+ } = useSearchPostsQuery({query: augmentedQuery, sort})
const onPullToRefresh = React.useCallback(async () => {
setIsPTR(true)
@@ -316,8 +323,6 @@ function SearchScreenUserResults({query}: {query: string}) {
)
}
-const SECTIONS_LOGGEDOUT = ['Users']
-const SECTIONS_LOGGEDIN = ['Posts', 'Users']
export function SearchScreenInner({
query,
primarySearch,
@@ -330,6 +335,9 @@ export function SearchScreenInner({
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
const {hasSession} = useSession()
const {isDesktop} = useWebMediaQueries()
+ const {_} = useLingui()
+
+ const isNewSearch = useGate('new_search')
const onPageSelected = React.useCallback(
(index: number) => {
@@ -339,6 +347,55 @@ export function SearchScreenInner({
[setDrawerSwipeDisabled, setMinimalShellMode],
)
+ const sections = React.useMemo(() => {
+ if (!query) return []
+ if (isNewSearch) {
+ if (hasSession) {
+ return [
+ {
+ title: _(msg`Top`),
+ component: ,
+ },
+ {
+ title: _(msg`Latest`),
+ component: ,
+ },
+ {
+ title: _(msg`People`),
+ component: ,
+ },
+ ]
+ } else {
+ return [
+ {
+ title: _(msg`People`),
+ component: ,
+ },
+ ]
+ }
+ } else {
+ if (hasSession) {
+ return [
+ {
+ title: _(msg`Posts`),
+ component: ,
+ },
+ {
+ title: _(msg`Users`),
+ component: ,
+ },
+ ]
+ } else {
+ return [
+ {
+ title: _(msg`Users`),
+ component: ,
+ },
+ ]
+ }
+ }
+ }, [hasSession, isNewSearch, _, query])
+
if (hasSession) {
return query ? (
-
+ section.title)} {...props} />
)}
initialPage={0}>
-
-
-
-
-
-
+ {sections.map((section, i) => (
+ {section.component}
+ ))}
) : (
@@ -389,13 +443,13 @@ export function SearchScreenInner({
-
+ section.title)} {...props} />
)}
initialPage={0}>
-
-
-
+ {sections.map((section, i) => (
+ {section.component}
+ ))}
) : (