flip types/bsky sources to #/lexicons, add chat client and strict hook contracts
Phase 3 task 2: types/bsky sibling modules (post/profile/starterPack) now source from generated #/lexicons with interim dual-world widening aliases (TODO(phase4) markers). SessionBundle gains a chatClient proxied to did:web:api.bsky.chat#bsky_chat via useChatClient(). usePdsClient() and useChatClient() no longer fall back to the public appview when logged out - they return a client that throws NotAuthenticatedError before any network I/O; useMaybePdsClient()/useMaybeChatClient() cover logged-out-aware callers. RichText pilot: useRichText.ts on @bsky.app/sdk/richtext with detectFacets(pdsClient); RichText.tsx display sink accepts both worlds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import {useMemo} from 'react'
|
import {useMemo} from 'react'
|
||||||
import {type StyleProp, type TextStyle} from 'react-native'
|
import {type StyleProp, type TextStyle} from 'react-native'
|
||||||
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
|
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
|
||||||
|
import {RichText as SdkRichText} from '@bsky.app/sdk/richtext'
|
||||||
|
|
||||||
import {toShortUrl} from '#/lib/strings/url-helpers'
|
import {toShortUrl} from '#/lib/strings/url-helpers'
|
||||||
import {atoms as a, flatten, type TextStyleProp} from '#/alf'
|
import {atoms as a, flatten, type TextStyleProp} from '#/alf'
|
||||||
@@ -17,7 +18,16 @@ const URL_REGEX =
|
|||||||
|
|
||||||
export type RichTextProps = TextStyleProp &
|
export type RichTextProps = TextStyleProp &
|
||||||
Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
|
Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
|
||||||
value: RichTextAPI | string
|
/*
|
||||||
|
* TODO(phase4): drop the `SdkRichText` arm and normalization below, keeping
|
||||||
|
* only the SDK RichText. Interim dual-world acceptance: the migrated
|
||||||
|
* `useRichText` hook now produces an `@bsky.app/sdk/richtext` RichText,
|
||||||
|
* while ~100 call sites still pass the old `@atproto/api` RichText produced
|
||||||
|
* elsewhere. We accept both and normalize an SDK instance into the old
|
||||||
|
* RichText below (its `.facets` flow new->old without a cast) so the render
|
||||||
|
* body stays single-typed until the RichText UI callers migrate (Task 7).
|
||||||
|
*/
|
||||||
|
value: RichTextAPI | SdkRichText | string
|
||||||
testID?: string
|
testID?: string
|
||||||
numberOfLines?: number
|
numberOfLines?: number
|
||||||
disableLinks?: boolean
|
disableLinks?: boolean
|
||||||
@@ -59,6 +69,13 @@ export function RichText({
|
|||||||
const richText = useMemo(() => {
|
const richText = useMemo(() => {
|
||||||
if (value instanceof RichTextAPI) {
|
if (value instanceof RichTextAPI) {
|
||||||
return value
|
return value
|
||||||
|
} else if (value instanceof SdkRichText) {
|
||||||
|
/*
|
||||||
|
* Normalize the SDK RichText into the old one this component renders
|
||||||
|
* against. `.facets` are structurally identical modulo branded strings
|
||||||
|
* (new->old assigns), so they carry over without a cast.
|
||||||
|
*/
|
||||||
|
return new RichTextAPI({text: value.text, facets: value.facets})
|
||||||
} else {
|
} else {
|
||||||
const rt = new RichTextAPI({text: value})
|
const rt = new RichTextAPI({text: value})
|
||||||
rt.detectFacetsWithoutResolution()
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
import {RichText as RichTextAPI} from '@atproto/api'
|
import {RichText as RichTextAPI} from '@bsky.app/sdk/richtext'
|
||||||
|
|
||||||
import {useAgent} from '#/state/session'
|
import {usePdsClient} from '#/state/session'
|
||||||
|
|
||||||
export function useRichText(text: string): [RichTextAPI, boolean] {
|
export function useRichText(text: string): [RichTextAPI, boolean] {
|
||||||
const [prevText, setPrevText] = useState(text)
|
const [prevText, setPrevText] = useState(text)
|
||||||
const [rawRT, setRawRT] = useState(() => new RichTextAPI({text}))
|
const [rawRT, setRawRT] = useState(() => new RichTextAPI({text}))
|
||||||
const [resolvedRT, setResolvedRT] = useState<RichTextAPI | null>(null)
|
const [resolvedRT, setResolvedRT] = useState<RichTextAPI | null>(null)
|
||||||
const agent = useAgent()
|
/*
|
||||||
|
* Facet detection resolves handles via `com.atproto.identity.resolveHandle`,
|
||||||
|
* which the account (PDS) client serves. We standardize on the account client
|
||||||
|
* where a session is in scope (design section B). Logged out, this is the
|
||||||
|
* throwing client; `detectFacets` will reject, and the raw (unresolved)
|
||||||
|
* RichText is returned in the meantime.
|
||||||
|
*/
|
||||||
|
const client = usePdsClient()
|
||||||
if (text !== prevText) {
|
if (text !== prevText) {
|
||||||
setPrevText(text)
|
setPrevText(text)
|
||||||
setRawRT(new RichTextAPI({text}))
|
setRawRT(new RichTextAPI({text}))
|
||||||
@@ -19,7 +26,7 @@ export function useRichText(text: string): [RichTextAPI, boolean] {
|
|||||||
async function resolveRTFacets() {
|
async function resolveRTFacets() {
|
||||||
// new each time
|
// new each time
|
||||||
const resolvedRT = new RichTextAPI({text})
|
const resolvedRT = new RichTextAPI({text})
|
||||||
await resolvedRT.detectFacets(agent)
|
await resolvedRT.detectFacets(client)
|
||||||
if (!ignore) {
|
if (!ignore) {
|
||||||
setResolvedRT(resolvedRT)
|
setResolvedRT(resolvedRT)
|
||||||
}
|
}
|
||||||
@@ -28,7 +35,7 @@ export function useRichText(text: string): [RichTextAPI, boolean] {
|
|||||||
return () => {
|
return () => {
|
||||||
ignore = true
|
ignore = true
|
||||||
}
|
}
|
||||||
}, [text, agent])
|
}, [text, client])
|
||||||
const isResolving = resolvedRT === null
|
const isResolving = resolvedRT === null
|
||||||
return [resolvedRT ?? rawRT, isResolving]
|
return [resolvedRT ?? rawRT, isResolving]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,14 @@ jest.mock('jwt-decode', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||||
import {app} from '#/lexicons'
|
import {app, chat} from '#/lexicons'
|
||||||
import {
|
import {
|
||||||
buildAccountClient,
|
buildAccountClient,
|
||||||
buildAppviewClient,
|
buildAppviewClient,
|
||||||
|
buildChatClient,
|
||||||
getPublicLexClient,
|
getPublicLexClient,
|
||||||
|
getUnauthenticatedClient,
|
||||||
|
NotAuthenticatedError,
|
||||||
} from '../clients'
|
} from '../clients'
|
||||||
import {sessionAccountToSessionData} from '../session-core'
|
import {sessionAccountToSessionData} from '../session-core'
|
||||||
import {type SessionAccount} from '../types'
|
import {type SessionAccount} from '../types'
|
||||||
@@ -41,6 +44,7 @@ const DID = 'did:plc:example123'
|
|||||||
const HANDLE = 'alice.test'
|
const HANDLE = 'alice.test'
|
||||||
const SERVICE = 'https://bsky.social'
|
const SERVICE = 'https://bsky.social'
|
||||||
const APPVIEW_PROXY = 'did:web:api.bsky.app#bsky_appview'
|
const APPVIEW_PROXY = 'did:web:api.bsky.app#bsky_appview'
|
||||||
|
const CHAT_PROXY = 'did:web:api.bsky.chat#bsky_chat'
|
||||||
const CUSTOM_LABELER = 'did:plc:custom-labeler'
|
const CUSTOM_LABELER = 'did:plc:custom-labeler'
|
||||||
|
|
||||||
function makeAccount(overrides: Partial<SessionAccount> = {}): SessionAccount {
|
function makeAccount(overrides: Partial<SessionAccount> = {}): SessionAccount {
|
||||||
@@ -175,6 +179,72 @@ describe('buildAccountClient', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('buildChatClient', () => {
|
||||||
|
it('sets the chat atproto-proxy header on a request', async () => {
|
||||||
|
const {seen, fetchMock} = makeCapturingFetch()
|
||||||
|
const session = makeSession(fetchMock)
|
||||||
|
const client = buildChatClient(session)
|
||||||
|
|
||||||
|
await client.call(chat.bsky.convo.listConvos.main).catch(() => {})
|
||||||
|
|
||||||
|
expect(seen.length).toBe(1)
|
||||||
|
expect(seen[0].headers.get('atproto-proxy')).toBe(CHAT_PROXY)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes through the session fetchHandler with the bearer token', async () => {
|
||||||
|
const {seen, fetchMock} = makeCapturingFetch()
|
||||||
|
const session = makeSession(fetchMock)
|
||||||
|
const client = buildChatClient(session)
|
||||||
|
|
||||||
|
await client.call(chat.bsky.convo.listConvos.main).catch(() => {})
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getUnauthenticatedClient', () => {
|
||||||
|
it('is a stable singleton with no did', () => {
|
||||||
|
const client = getUnauthenticatedClient()
|
||||||
|
expect(client.did).toBeUndefined()
|
||||||
|
/* identity is stable so it is safe in React Query keys */
|
||||||
|
expect(getUnauthenticatedClient()).toBe(client)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects on a call, with NotAuthenticatedError as the root cause', async () => {
|
||||||
|
/*
|
||||||
|
* The throwing fetchHandler fires before any network I/O. lex-client wraps
|
||||||
|
* a fetchHandler throw in an XrpcInternalError whose `.cause` is the
|
||||||
|
* original error, so the NotAuthenticatedError surfaces as the cause.
|
||||||
|
*/
|
||||||
|
const client = getUnauthenticatedClient()
|
||||||
|
|
||||||
|
const err = await client
|
||||||
|
.call(chat.bsky.convo.listConvos.main)
|
||||||
|
.then(() => undefined)
|
||||||
|
.catch((e: unknown) => e)
|
||||||
|
|
||||||
|
expect(err).toBeInstanceOf(Error)
|
||||||
|
expect((err as Error).cause).toBeInstanceOf(NotAuthenticatedError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces a NotAuthenticatedError with a stable name and message', async () => {
|
||||||
|
const client = getUnauthenticatedClient()
|
||||||
|
|
||||||
|
const err = await client
|
||||||
|
.call(chat.bsky.convo.listConvos.main)
|
||||||
|
.then(() => undefined)
|
||||||
|
.catch((e: unknown) => e)
|
||||||
|
|
||||||
|
const cause = (err as Error).cause
|
||||||
|
expect(cause).toBeInstanceOf(NotAuthenticatedError)
|
||||||
|
expect((cause as NotAuthenticatedError).name).toBe('NotAuthenticatedError')
|
||||||
|
expect((cause as NotAuthenticatedError).message).toBe(
|
||||||
|
'Not authenticated: this operation requires an active session',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('getPublicLexClient', () => {
|
describe('getPublicLexClient', () => {
|
||||||
it('is an unauthenticated singleton (no session did)', () => {
|
it('is an unauthenticated singleton (no session did)', () => {
|
||||||
const client = getPublicLexClient()
|
const client = getPublicLexClient()
|
||||||
|
|||||||
@@ -87,6 +87,51 @@ export function buildAccountClient(session: PasswordSession): Client {
|
|||||||
return new Client(session)
|
return new Client(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the chat client over a {@link PasswordSession}.
|
||||||
|
*
|
||||||
|
* `api.chat.service` (`did:web:api.bsky.chat#bsky_chat`) is passed as the
|
||||||
|
* client's `service`, so lex-client sets `atproto-proxy: did:web:api.bsky.chat#bsky_chat`
|
||||||
|
* on every request. This is exactly what the old per-call `DM_SERVICE_HEADERS`
|
||||||
|
* did, once and centrally, so `chat.bsky.*` calls are proxied to the chat
|
||||||
|
* service.
|
||||||
|
*/
|
||||||
|
export function buildChatClient(session: PasswordSession): Client {
|
||||||
|
return new Client(session, {service: api.chat.service})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown when a write/auth-only client is used with no active session. */
|
||||||
|
export class NotAuthenticatedError extends Error {
|
||||||
|
constructor(op = 'this operation') {
|
||||||
|
super(`Not authenticated: ${op} requires an active session`)
|
||||||
|
this.name = 'NotAuthenticatedError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stable {@link Client} that throws {@link NotAuthenticatedError} on any
|
||||||
|
* request, before any network I/O. Used as the logged-out value of write/auth
|
||||||
|
* -only hooks (`usePdsClient`/`useChatClient`) so an unauthenticated write or
|
||||||
|
* chat call fails immediately and legibly instead of silently hitting the
|
||||||
|
* public appview (which would 404/405 with an opaque error).
|
||||||
|
*
|
||||||
|
* A lazily-constructed process-wide singleton, so its identity is stable across
|
||||||
|
* renders - safe to use in React Query keys and as a hook return value. The
|
||||||
|
* `did` is `undefined` (logged out) and the `fetchHandler` throws before
|
||||||
|
* touching the network.
|
||||||
|
*/
|
||||||
|
let unauthedClient: Client | undefined
|
||||||
|
|
||||||
|
export function getUnauthenticatedClient(): Client {
|
||||||
|
unauthedClient ??= new Client({
|
||||||
|
did: undefined,
|
||||||
|
fetchHandler: () => {
|
||||||
|
throw new NotAuthenticatedError()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return unauthedClient
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the authed appview client over a {@link PasswordSession}.
|
* Build the authed appview client over a {@link PasswordSession}.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
|
|||||||
import {IS_WEB} from '#/env'
|
import {IS_WEB} from '#/env'
|
||||||
import {com} from '#/lexicons'
|
import {com} from '#/lexicons'
|
||||||
import {emitSessionDropped} from '../events'
|
import {emitSessionDropped} from '../events'
|
||||||
import {getPublicLexClient} from './clients'
|
import {getPublicLexClient, getUnauthenticatedClient} from './clients'
|
||||||
import {type Action, getInitialState, reducer, type State} from './reducer'
|
import {type Action, getInitialState, reducer, type State} from './reducer'
|
||||||
import {
|
import {
|
||||||
buildBundle,
|
buildBundle,
|
||||||
@@ -571,9 +571,59 @@ export function useAppviewClient(): Client {
|
|||||||
/**
|
/**
|
||||||
* The account (PDS) lex {@link Client} for the active account. Writes and record
|
* The account (PDS) lex {@link Client} for the active account. Writes and record
|
||||||
* mutations go here - requests hit the user's PDS directly (no appview proxy).
|
* mutations go here - requests hit the user's PDS directly (no appview proxy).
|
||||||
* Falls back to the public client when there is no bundle.
|
*
|
||||||
|
* Logged-out contract: returns a stable throwing client
|
||||||
|
* ({@link getUnauthenticatedClient}) that throws `NotAuthenticatedError` on any
|
||||||
|
* request, BEFORE any network I/O. This is the write path - it must NOT fall
|
||||||
|
* back to the public appview, so an unauthenticated write fails immediately and
|
||||||
|
* legibly rather than silently hitting `public.api.bsky.app`. Components may
|
||||||
|
* safely hold this client while logged out; only calling it throws. A component
|
||||||
|
* that genuinely branches on auth state should use {@link useMaybePdsClient}.
|
||||||
*/
|
*/
|
||||||
export function usePdsClient(): Client {
|
export function usePdsClient(): Client {
|
||||||
const bundle = useContext(BundleContext)
|
const bundle = useContext(BundleContext)
|
||||||
return bundle?.accountClient ?? getPublicLexClient()
|
return bundle?.accountClient ?? getUnauthenticatedClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chat lex {@link Client} for the active account. `chat.bsky.*` calls go
|
||||||
|
* here - proxied to `did:web:api.bsky.chat#bsky_chat`.
|
||||||
|
*
|
||||||
|
* Logged-out contract: returns a stable throwing client
|
||||||
|
* ({@link getUnauthenticatedClient}) that throws `NotAuthenticatedError` on any
|
||||||
|
* request, BEFORE any network I/O. Chat is meaningless logged out, so this must
|
||||||
|
* NOT fall back to the public appview. A component that genuinely branches on
|
||||||
|
* auth state should use {@link useMaybeChatClient}.
|
||||||
|
*/
|
||||||
|
export function useChatClient(): Client {
|
||||||
|
const bundle = useContext(BundleContext)
|
||||||
|
return bundle?.chatClient ?? getUnauthenticatedClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account (PDS) lex {@link Client} for the active account, or `null` when
|
||||||
|
* there is no active session (logged out, or used outside the provider).
|
||||||
|
*
|
||||||
|
* The escape hatch for the rare component that genuinely renders a logged-out
|
||||||
|
* branch and must decide whether a write path is available. Prefer
|
||||||
|
* {@link usePdsClient} for the common case (a write only reachable while
|
||||||
|
* authenticated); do NOT reach for this hook merely to dodge the throwing
|
||||||
|
* client's `NotAuthenticatedError`.
|
||||||
|
*/
|
||||||
|
export function useMaybePdsClient(): Client | null {
|
||||||
|
const bundle = useContext(BundleContext)
|
||||||
|
return bundle?.session ? bundle.accountClient : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chat lex {@link Client} for the active account, or `null` when there is
|
||||||
|
* no active session (logged out, or used outside the provider).
|
||||||
|
*
|
||||||
|
* The escape hatch for the rare component that genuinely renders a logged-out
|
||||||
|
* branch. Prefer {@link useChatClient} for the common case; do NOT reach for
|
||||||
|
* this hook merely to dodge the throwing client's `NotAuthenticatedError`.
|
||||||
|
*/
|
||||||
|
export function useMaybeChatClient(): Client | null {
|
||||||
|
const bundle = useContext(BundleContext)
|
||||||
|
return bundle?.session ? bundle.chatClient : null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ import {features} from '#/analytics'
|
|||||||
import {
|
import {
|
||||||
buildAccountClient,
|
buildAccountClient,
|
||||||
buildAppviewClient,
|
buildAppviewClient,
|
||||||
|
buildChatClient,
|
||||||
getPublicLexClient,
|
getPublicLexClient,
|
||||||
|
getUnauthenticatedClient,
|
||||||
} from './clients'
|
} from './clients'
|
||||||
import {addSessionErrorLog} from './logging'
|
import {addSessionErrorLog} from './logging'
|
||||||
import {
|
import {
|
||||||
@@ -401,6 +403,8 @@ export type SessionBundle = {
|
|||||||
accountClient: Client
|
accountClient: Client
|
||||||
/** Authed appview client (proxied, with labelers). */
|
/** Authed appview client (proxied, with labelers). */
|
||||||
appviewClient: Client
|
appviewClient: Client
|
||||||
|
/** Chat client (proxied to `did:web:api.bsky.chat#bsky_chat`). */
|
||||||
|
chatClient: Client
|
||||||
/**
|
/**
|
||||||
* The service (entryway) URL, mirroring `agent.serviceUrl`. Exposed so the
|
* The service (entryway) URL, mirroring `agent.serviceUrl`. Exposed so the
|
||||||
* reducer can read `.service` for its opaque snapshot/logging view
|
* reducer can read `.service` for its opaque snapshot/logging view
|
||||||
@@ -431,6 +435,7 @@ export function buildBundle(session: PasswordSession): SessionBundle {
|
|||||||
* appviewClient too.
|
* appviewClient too.
|
||||||
*/
|
*/
|
||||||
appviewClient: buildAppviewClient(session, []),
|
appviewClient: buildAppviewClient(session, []),
|
||||||
|
chatClient: buildChatClient(session),
|
||||||
/*
|
/*
|
||||||
* Mirror the bridge agent's serviceUrl so the reducer's opaque view can
|
* Mirror the bridge agent's serviceUrl so the reducer's opaque view can
|
||||||
* read `.service`. A getter keeps it live with the agent's derivation.
|
* read `.service`. A getter keeps it live with the agent's derivation.
|
||||||
@@ -519,6 +524,13 @@ export type PublicSessionBundle = {
|
|||||||
agent: SessionAgent
|
agent: SessionAgent
|
||||||
accountClient: Client
|
accountClient: Client
|
||||||
appviewClient: Client
|
appviewClient: Client
|
||||||
|
/**
|
||||||
|
* The throwing unauthenticated client (NOT the public client): chat is
|
||||||
|
* meaningless logged out, and `useChatClient()` must fail loudly rather than
|
||||||
|
* silently target the public appview. See {@link getUnauthenticatedClient}
|
||||||
|
* and design section J.
|
||||||
|
*/
|
||||||
|
chatClient: Client
|
||||||
/** Mirrors `agent.serviceUrl` (the public appview URL). See {@link SessionBundle.service}. */
|
/** Mirrors `agent.serviceUrl` (the public appview URL). See {@link SessionBundle.service}. */
|
||||||
readonly service: URL
|
readonly service: URL
|
||||||
}
|
}
|
||||||
@@ -536,8 +548,16 @@ export function createPublicSessionBundle(): PublicSessionBundle {
|
|||||||
return {
|
return {
|
||||||
session: null,
|
session: null,
|
||||||
agent,
|
agent,
|
||||||
accountClient: publicClient,
|
/*
|
||||||
|
* Write/auth clients throw on use when logged out (design section J): the
|
||||||
|
* public bundle exposes the throwing unauthenticated client for the account
|
||||||
|
* (PDS) and chat clients so an unauthenticated write or chat call fails
|
||||||
|
* loudly instead of silently targeting the public appview. Reads keep the
|
||||||
|
* public client (appviewClient), which reads public data without auth.
|
||||||
|
*/
|
||||||
|
accountClient: getUnauthenticatedClient(),
|
||||||
appviewClient: publicClient,
|
appviewClient: publicClient,
|
||||||
|
chatClient: getUnauthenticatedClient(),
|
||||||
get service() {
|
get service() {
|
||||||
return agent.serviceUrl
|
return agent.serviceUrl
|
||||||
},
|
},
|
||||||
|
|||||||
+91
-38
@@ -1,64 +1,105 @@
|
|||||||
import {
|
import {
|
||||||
type $Typed,
|
type $Typed as $TypedApi,
|
||||||
AppBskyEmbedExternal,
|
type AppBskyEmbedExternal,
|
||||||
AppBskyEmbedGallery,
|
type AppBskyEmbedGallery,
|
||||||
AppBskyEmbedImages,
|
type AppBskyEmbedImages,
|
||||||
AppBskyEmbedRecord,
|
type AppBskyEmbedRecord,
|
||||||
AppBskyEmbedRecordWithMedia,
|
type AppBskyEmbedVideo,
|
||||||
AppBskyEmbedVideo,
|
type AppBskyFeedDefs,
|
||||||
AppBskyFeedDefs,
|
type AppBskyGraphDefs,
|
||||||
AppBskyGraphDefs,
|
type AppBskyLabelerDefs,
|
||||||
AppBskyLabelerDefs,
|
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
import {type $Typed} from '@atproto/lex'
|
||||||
|
|
||||||
|
import {app} from '#/lexicons'
|
||||||
|
import {isType} from '#/types/bsky'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* TODO(phase4): drop every `| $TypedApi<AppBsky*>` arm below. This is a
|
||||||
|
* dual-world widening of the `Embed` union for the migration interim: the
|
||||||
|
* `.view` slots are populated both by `parseEmbed` (which returns `#/lexicons`
|
||||||
|
* views, the target) and by call sites that still pass old `@atproto/api`
|
||||||
|
* views produced through the bridge agent (e.g. ExternalEmbed, LazyQuoteEmbed).
|
||||||
|
* Each variant therefore accepts both worlds until those producers flip, after
|
||||||
|
* which the old arms are removed and this becomes a pure new-world union.
|
||||||
|
*/
|
||||||
export type Embed =
|
export type Embed =
|
||||||
| {
|
| {
|
||||||
type: 'post'
|
type: 'post'
|
||||||
view: $Typed<AppBskyEmbedRecord.ViewRecord>
|
view:
|
||||||
|
| $Typed<app.bsky.embed.record.ViewRecord>
|
||||||
|
| $TypedApi<AppBskyEmbedRecord.ViewRecord>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'post_not_found'
|
type: 'post_not_found'
|
||||||
view: $Typed<AppBskyEmbedRecord.ViewNotFound>
|
view:
|
||||||
|
| $Typed<app.bsky.embed.record.ViewNotFound>
|
||||||
|
| $TypedApi<AppBskyEmbedRecord.ViewNotFound>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'post_blocked'
|
type: 'post_blocked'
|
||||||
view: $Typed<AppBskyEmbedRecord.ViewBlocked>
|
view:
|
||||||
|
| $Typed<app.bsky.embed.record.ViewBlocked>
|
||||||
|
| $TypedApi<AppBskyEmbedRecord.ViewBlocked>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'post_detached'
|
type: 'post_detached'
|
||||||
view: $Typed<AppBskyEmbedRecord.ViewDetached>
|
view:
|
||||||
|
| $Typed<app.bsky.embed.record.ViewDetached>
|
||||||
|
| $TypedApi<AppBskyEmbedRecord.ViewDetached>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'feed'
|
type: 'feed'
|
||||||
view: $Typed<AppBskyFeedDefs.GeneratorView>
|
view:
|
||||||
|
| $Typed<app.bsky.feed.defs.GeneratorView>
|
||||||
|
| $TypedApi<AppBskyFeedDefs.GeneratorView>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'list'
|
type: 'list'
|
||||||
view: $Typed<AppBskyGraphDefs.ListView>
|
view:
|
||||||
|
| $Typed<app.bsky.graph.defs.ListView>
|
||||||
|
| $TypedApi<AppBskyGraphDefs.ListView>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'labeler'
|
type: 'labeler'
|
||||||
view: $Typed<AppBskyLabelerDefs.LabelerView>
|
view:
|
||||||
|
| $Typed<app.bsky.labeler.defs.LabelerView>
|
||||||
|
| $TypedApi<AppBskyLabelerDefs.LabelerView>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'starter_pack'
|
type: 'starter_pack'
|
||||||
view: $Typed<AppBskyGraphDefs.StarterPackViewBasic>
|
view:
|
||||||
|
| $Typed<app.bsky.graph.defs.StarterPackViewBasic>
|
||||||
|
| $TypedApi<AppBskyGraphDefs.StarterPackViewBasic>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'images'
|
type: 'images'
|
||||||
view: $Typed<AppBskyEmbedImages.View>
|
/*
|
||||||
|
* TODO(phase4): flip to `$Typed<app.bsky.embed.images.View>`. Kept on the
|
||||||
|
* old `@atproto/api` view for now because the ImageEmbed consumer narrows
|
||||||
|
* gallery/images items with old-world `is*` guards that do not narrow the
|
||||||
|
* new union's `Unknown$TypedObject` arm. `parseEmbed` produces a new view
|
||||||
|
* here; new->old assignability lets it flow into this old slot until the
|
||||||
|
* consumer migrates (Task 7).
|
||||||
|
*/
|
||||||
|
view: $TypedApi<AppBskyEmbedImages.View>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'gallery'
|
type: 'gallery'
|
||||||
view: $Typed<AppBskyEmbedGallery.View>
|
/** TODO(phase4): flip to `$Typed<app.bsky.embed.gallery.View>` - see the `images` arm above. */
|
||||||
|
view: $TypedApi<AppBskyEmbedGallery.View>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'link'
|
type: 'link'
|
||||||
view: $Typed<AppBskyEmbedExternal.View>
|
view:
|
||||||
|
| $Typed<app.bsky.embed.external.View>
|
||||||
|
| $TypedApi<AppBskyEmbedExternal.View>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'video'
|
type: 'video'
|
||||||
view: $Typed<AppBskyEmbedVideo.View>
|
view:
|
||||||
|
| $Typed<app.bsky.embed.video.View>
|
||||||
|
| $TypedApi<AppBskyEmbedVideo.View>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'post_with_media'
|
type: 'post_with_media'
|
||||||
@@ -72,43 +113,45 @@ export type Embed =
|
|||||||
|
|
||||||
export type EmbedType<T extends Embed['type']> = Extract<Embed, {type: T}>
|
export type EmbedType<T extends Embed['type']> = Extract<Embed, {type: T}>
|
||||||
|
|
||||||
export function parseEmbedRecordView({record}: AppBskyEmbedRecord.View): Embed {
|
export function parseEmbedRecordView({
|
||||||
if (AppBskyEmbedRecord.isViewRecord(record)) {
|
record,
|
||||||
|
}: app.bsky.embed.record.View): Embed {
|
||||||
|
if (isType(app.bsky.embed.record.viewRecord, record)) {
|
||||||
return {
|
return {
|
||||||
type: 'post',
|
type: 'post',
|
||||||
view: record,
|
view: record,
|
||||||
}
|
}
|
||||||
} else if (AppBskyEmbedRecord.isViewNotFound(record)) {
|
} else if (isType(app.bsky.embed.record.viewNotFound, record)) {
|
||||||
return {
|
return {
|
||||||
type: 'post_not_found',
|
type: 'post_not_found',
|
||||||
view: record,
|
view: record,
|
||||||
}
|
}
|
||||||
} else if (AppBskyEmbedRecord.isViewBlocked(record)) {
|
} else if (isType(app.bsky.embed.record.viewBlocked, record)) {
|
||||||
return {
|
return {
|
||||||
type: 'post_blocked',
|
type: 'post_blocked',
|
||||||
view: record,
|
view: record,
|
||||||
}
|
}
|
||||||
} else if (AppBskyEmbedRecord.isViewDetached(record)) {
|
} else if (isType(app.bsky.embed.record.viewDetached, record)) {
|
||||||
return {
|
return {
|
||||||
type: 'post_detached',
|
type: 'post_detached',
|
||||||
view: record,
|
view: record,
|
||||||
}
|
}
|
||||||
} else if (AppBskyFeedDefs.isGeneratorView(record)) {
|
} else if (isType(app.bsky.feed.defs.generatorView, record)) {
|
||||||
return {
|
return {
|
||||||
type: 'feed',
|
type: 'feed',
|
||||||
view: record,
|
view: record,
|
||||||
}
|
}
|
||||||
} else if (AppBskyGraphDefs.isListView(record)) {
|
} else if (isType(app.bsky.graph.defs.listView, record)) {
|
||||||
return {
|
return {
|
||||||
type: 'list',
|
type: 'list',
|
||||||
view: record,
|
view: record,
|
||||||
}
|
}
|
||||||
} else if (AppBskyLabelerDefs.isLabelerView(record)) {
|
} else if (isType(app.bsky.labeler.defs.labelerView, record)) {
|
||||||
return {
|
return {
|
||||||
type: 'labeler',
|
type: 'labeler',
|
||||||
view: record,
|
view: record,
|
||||||
}
|
}
|
||||||
} else if (AppBskyGraphDefs.isStarterPackViewBasic(record)) {
|
} else if (isType(app.bsky.graph.defs.starterPackViewBasic, record)) {
|
||||||
return {
|
return {
|
||||||
type: 'starter_pack',
|
type: 'starter_pack',
|
||||||
view: record,
|
view: record,
|
||||||
@@ -121,30 +164,40 @@ export function parseEmbedRecordView({record}: AppBskyEmbedRecord.View): Embed {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed {
|
export function parseEmbed(
|
||||||
if (AppBskyEmbedImages.isView(embed)) {
|
/*
|
||||||
|
* TODO(phase4): drop the `| AppBskyFeedDefs.PostView['embed']` arm. Widened
|
||||||
|
* for the interim so call sites still passing an old bridge-produced
|
||||||
|
* `PostView.embed` typecheck against the `#/lexicons` guards below (which
|
||||||
|
* narrow on `$type` regardless of world).
|
||||||
|
*/
|
||||||
|
embed:
|
||||||
|
| app.bsky.feed.defs.PostView['embed']
|
||||||
|
| AppBskyFeedDefs.PostView['embed'],
|
||||||
|
): Embed {
|
||||||
|
if (isType(app.bsky.embed.images.view, embed)) {
|
||||||
return {
|
return {
|
||||||
type: 'images',
|
type: 'images',
|
||||||
view: embed,
|
view: embed,
|
||||||
}
|
}
|
||||||
} else if (AppBskyEmbedGallery.isView(embed)) {
|
} else if (isType(app.bsky.embed.gallery.view, embed)) {
|
||||||
return {
|
return {
|
||||||
type: 'gallery',
|
type: 'gallery',
|
||||||
view: embed,
|
view: embed,
|
||||||
}
|
}
|
||||||
} else if (AppBskyEmbedExternal.isView(embed)) {
|
} else if (isType(app.bsky.embed.external.view, embed)) {
|
||||||
return {
|
return {
|
||||||
type: 'link',
|
type: 'link',
|
||||||
view: embed,
|
view: embed,
|
||||||
}
|
}
|
||||||
} else if (AppBskyEmbedVideo.isView(embed)) {
|
} else if (isType(app.bsky.embed.video.view, embed)) {
|
||||||
return {
|
return {
|
||||||
type: 'video',
|
type: 'video',
|
||||||
view: embed,
|
view: embed,
|
||||||
}
|
}
|
||||||
} else if (AppBskyEmbedRecord.isView(embed)) {
|
} else if (isType(app.bsky.embed.record.view, embed)) {
|
||||||
return parseEmbedRecordView(embed)
|
return parseEmbedRecordView(embed)
|
||||||
} else if (AppBskyEmbedRecordWithMedia.isView(embed)) {
|
} else if (isType(app.bsky.embed.recordWithMedia.view, embed)) {
|
||||||
return {
|
return {
|
||||||
type: 'post_with_media',
|
type: 'post_with_media',
|
||||||
view: parseEmbedRecordView(embed.record),
|
view: parseEmbedRecordView(embed.record),
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
import {type AppBskyActorDefs, type ChatBskyActorDefs} from '@atproto/api'
|
import {type AppBskyActorDefs, type ChatBskyActorDefs} from '@atproto/api'
|
||||||
|
|
||||||
|
import {type app, type chat} from '#/lexicons'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Matches any profile view exported by our SDK
|
* Matches any profile view exported by our SDK.
|
||||||
|
*
|
||||||
|
* TODO(phase4): drop the `@atproto/api` arms. This is a dual-world widening
|
||||||
|
* alias for the migration interim: profile producers (state/queries/profile.ts
|
||||||
|
* etc.) still return old `@atproto/api` views via the bridge agent, so the
|
||||||
|
* union must accept both the new `#/lexicons` views (the target) and the old
|
||||||
|
* ones until those producers flip. Once every producer emits `#/lexicons`
|
||||||
|
* views, remove the old arms and this becomes a pure new-world union.
|
||||||
*/
|
*/
|
||||||
export type AnyProfileView =
|
export type AnyProfileView =
|
||||||
|
| app.bsky.actor.defs.ProfileViewBasic
|
||||||
|
| app.bsky.actor.defs.ProfileView
|
||||||
|
| app.bsky.actor.defs.ProfileViewDetailed
|
||||||
|
| chat.bsky.actor.defs.ProfileViewBasic
|
||||||
| AppBskyActorDefs.ProfileViewBasic
|
| AppBskyActorDefs.ProfileViewBasic
|
||||||
| AppBskyActorDefs.ProfileView
|
| AppBskyActorDefs.ProfileView
|
||||||
| AppBskyActorDefs.ProfileViewDetailed
|
| AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
|||||||
@@ -1,11 +1,43 @@
|
|||||||
import {AppBskyGraphDefs} from '@atproto/api'
|
import {type AppBskyGraphDefs} from '@atproto/api'
|
||||||
|
|
||||||
export const isBasicView = AppBskyGraphDefs.isStarterPackViewBasic
|
import {app} from '#/lexicons'
|
||||||
export const isView = AppBskyGraphDefs.isStarterPackView
|
|
||||||
|
/*
|
||||||
|
* The generated `$type`-only guards. The old `@atproto/api`
|
||||||
|
* `AppBskyGraphDefs.isStarterPackView*` helpers matched on a present,
|
||||||
|
* matching `$type`; we reproduce that here against the `#/lexicons` schema's
|
||||||
|
* `$type` string rather than delegating to the schema's `isTypeOf` (which
|
||||||
|
* treats a missing `$type` as a match).
|
||||||
|
*/
|
||||||
|
export function isBasicView(
|
||||||
|
v: unknown,
|
||||||
|
): v is app.bsky.graph.defs.StarterPackViewBasic {
|
||||||
|
return (
|
||||||
|
v != null &&
|
||||||
|
typeof v === 'object' &&
|
||||||
|
(v as {$type?: unknown}).$type ===
|
||||||
|
app.bsky.graph.defs.starterPackViewBasic.$type
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isView(v: unknown): v is app.bsky.graph.defs.StarterPackView {
|
||||||
|
return (
|
||||||
|
v != null &&
|
||||||
|
typeof v === 'object' &&
|
||||||
|
(v as {$type?: unknown}).$type === app.bsky.graph.defs.starterPackView.$type
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Matches any starter pack view exported by our SDK
|
* Matches any starter pack view exported by our SDK.
|
||||||
|
*
|
||||||
|
* TODO(phase4): drop the `@atproto/api` arms. Dual-world widening alias for the
|
||||||
|
* migration interim - starter-pack producers still return old views via the
|
||||||
|
* bridge agent. Remove the old arms once every producer emits `#/lexicons`
|
||||||
|
* views.
|
||||||
*/
|
*/
|
||||||
export type AnyStarterPackView =
|
export type AnyStarterPackView =
|
||||||
|
| app.bsky.graph.defs.StarterPackViewBasic
|
||||||
|
| app.bsky.graph.defs.StarterPackView
|
||||||
| AppBskyGraphDefs.StarterPackViewBasic
|
| AppBskyGraphDefs.StarterPackViewBasic
|
||||||
| AppBskyGraphDefs.StarterPackView
|
| AppBskyGraphDefs.StarterPackView
|
||||||
|
|||||||
Reference in New Issue
Block a user