diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 25af851723..befd28178b 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -1,6 +1,7 @@ import {useMemo} from 'react' import {type StyleProp, type TextStyle} from 'react-native' 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 {atoms as a, flatten, type TextStyleProp} from '#/alf' @@ -17,7 +18,16 @@ const URL_REGEX = export type RichTextProps = TextStyleProp & Pick & { - 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 numberOfLines?: number disableLinks?: boolean @@ -59,6 +69,13 @@ export function RichText({ const richText = useMemo(() => { if (value instanceof RichTextAPI) { 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 { const rt = new RichTextAPI({text: value}) rt.detectFacetsWithoutResolution() diff --git a/src/components/hooks/useRichText.ts b/src/components/hooks/useRichText.ts index a1334bb635..90cb59f3d0 100644 --- a/src/components/hooks/useRichText.ts +++ b/src/components/hooks/useRichText.ts @@ -1,13 +1,20 @@ 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] { const [prevText, setPrevText] = useState(text) const [rawRT, setRawRT] = useState(() => new RichTextAPI({text})) const [resolvedRT, setResolvedRT] = useState(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) { setPrevText(text) setRawRT(new RichTextAPI({text})) @@ -19,7 +26,7 @@ export function useRichText(text: string): [RichTextAPI, boolean] { async function resolveRTFacets() { // new each time const resolvedRT = new RichTextAPI({text}) - await resolvedRT.detectFacets(agent) + await resolvedRT.detectFacets(client) if (!ignore) { setResolvedRT(resolvedRT) } @@ -28,7 +35,7 @@ export function useRichText(text: string): [RichTextAPI, boolean] { return () => { ignore = true } - }, [text, agent]) + }, [text, client]) const isResolving = resolvedRT === null return [resolvedRT ?? rawRT, isResolving] } diff --git a/src/state/session/__tests__/clients-bundle-test.ts b/src/state/session/__tests__/clients-bundle-test.ts index 3a47b578c5..97283b8d2c 100644 --- a/src/state/session/__tests__/clients-bundle-test.ts +++ b/src/state/session/__tests__/clients-bundle-test.ts @@ -28,11 +28,14 @@ jest.mock('jwt-decode', () => ({ })) import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' -import {app} from '#/lexicons' +import {app, chat} from '#/lexicons' import { buildAccountClient, buildAppviewClient, + buildChatClient, getPublicLexClient, + getUnauthenticatedClient, + NotAuthenticatedError, } from '../clients' import {sessionAccountToSessionData} from '../session-core' import {type SessionAccount} from '../types' @@ -41,6 +44,7 @@ const DID = 'did:plc:example123' const HANDLE = 'alice.test' const SERVICE = 'https://bsky.social' 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' function makeAccount(overrides: Partial = {}): 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', () => { it('is an unauthenticated singleton (no session did)', () => { const client = getPublicLexClient() diff --git a/src/state/session/clients.ts b/src/state/session/clients.ts index 2633160b1e..6cd8517828 100644 --- a/src/state/session/clients.ts +++ b/src/state/session/clients.ts @@ -87,6 +87,51 @@ export function buildAccountClient(session: PasswordSession): Client { 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}. * diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 98079c44cd..ad1e7c78cb 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -19,7 +19,7 @@ import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics' import {IS_WEB} from '#/env' import {com} from '#/lexicons' import {emitSessionDropped} from '../events' -import {getPublicLexClient} from './clients' +import {getPublicLexClient, getUnauthenticatedClient} from './clients' import {type Action, getInitialState, reducer, type State} from './reducer' import { buildBundle, @@ -571,9 +571,59 @@ export function useAppviewClient(): Client { /** * 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). - * 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 { 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 } diff --git a/src/state/session/session-core.ts b/src/state/session/session-core.ts index 3e3ec725f4..89cfa0c426 100644 --- a/src/state/session/session-core.ts +++ b/src/state/session/session-core.ts @@ -38,7 +38,9 @@ import {features} from '#/analytics' import { buildAccountClient, buildAppviewClient, + buildChatClient, getPublicLexClient, + getUnauthenticatedClient, } from './clients' import {addSessionErrorLog} from './logging' import { @@ -401,6 +403,8 @@ export type SessionBundle = { accountClient: Client /** Authed appview client (proxied, with labelers). */ 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 * reducer can read `.service` for its opaque snapshot/logging view @@ -431,6 +435,7 @@ export function buildBundle(session: PasswordSession): SessionBundle { * appviewClient too. */ appviewClient: buildAppviewClient(session, []), + chatClient: buildChatClient(session), /* * 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. @@ -519,6 +524,13 @@ export type PublicSessionBundle = { agent: SessionAgent accountClient: 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}. */ readonly service: URL } @@ -536,8 +548,16 @@ export function createPublicSessionBundle(): PublicSessionBundle { return { session: null, 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, + chatClient: getUnauthenticatedClient(), get service() { return agent.serviceUrl }, diff --git a/src/types/bsky/post.ts b/src/types/bsky/post.ts index 43621ef63b..45a3602bcc 100644 --- a/src/types/bsky/post.ts +++ b/src/types/bsky/post.ts @@ -1,64 +1,105 @@ import { - type $Typed, - AppBskyEmbedExternal, - AppBskyEmbedGallery, - AppBskyEmbedImages, - AppBskyEmbedRecord, - AppBskyEmbedRecordWithMedia, - AppBskyEmbedVideo, - AppBskyFeedDefs, - AppBskyGraphDefs, - AppBskyLabelerDefs, + type $Typed as $TypedApi, + type AppBskyEmbedExternal, + type AppBskyEmbedGallery, + type AppBskyEmbedImages, + type AppBskyEmbedRecord, + type AppBskyEmbedVideo, + type AppBskyFeedDefs, + type AppBskyGraphDefs, + type AppBskyLabelerDefs, } from '@atproto/api' +import {type $Typed} from '@atproto/lex' +import {app} from '#/lexicons' +import {isType} from '#/types/bsky' + +/* + * TODO(phase4): drop every `| $TypedApi` 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 = | { type: 'post' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'post_not_found' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'post_blocked' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'post_detached' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'feed' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'list' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'labeler' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'starter_pack' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'images' - view: $Typed + /* + * TODO(phase4): flip to `$Typed`. 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 } | { type: 'gallery' - view: $Typed + /** TODO(phase4): flip to `$Typed` - see the `images` arm above. */ + view: $TypedApi } | { type: 'link' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'video' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'post_with_media' @@ -72,43 +113,45 @@ export type Embed = export type EmbedType = Extract -export function parseEmbedRecordView({record}: AppBskyEmbedRecord.View): Embed { - if (AppBskyEmbedRecord.isViewRecord(record)) { +export function parseEmbedRecordView({ + record, +}: app.bsky.embed.record.View): Embed { + if (isType(app.bsky.embed.record.viewRecord, record)) { return { type: 'post', view: record, } - } else if (AppBskyEmbedRecord.isViewNotFound(record)) { + } else if (isType(app.bsky.embed.record.viewNotFound, record)) { return { type: 'post_not_found', view: record, } - } else if (AppBskyEmbedRecord.isViewBlocked(record)) { + } else if (isType(app.bsky.embed.record.viewBlocked, record)) { return { type: 'post_blocked', view: record, } - } else if (AppBskyEmbedRecord.isViewDetached(record)) { + } else if (isType(app.bsky.embed.record.viewDetached, record)) { return { type: 'post_detached', view: record, } - } else if (AppBskyFeedDefs.isGeneratorView(record)) { + } else if (isType(app.bsky.feed.defs.generatorView, record)) { return { type: 'feed', view: record, } - } else if (AppBskyGraphDefs.isListView(record)) { + } else if (isType(app.bsky.graph.defs.listView, record)) { return { type: 'list', view: record, } - } else if (AppBskyLabelerDefs.isLabelerView(record)) { + } else if (isType(app.bsky.labeler.defs.labelerView, record)) { return { type: 'labeler', view: record, } - } else if (AppBskyGraphDefs.isStarterPackViewBasic(record)) { + } else if (isType(app.bsky.graph.defs.starterPackViewBasic, record)) { return { type: 'starter_pack', view: record, @@ -121,30 +164,40 @@ export function parseEmbedRecordView({record}: AppBskyEmbedRecord.View): Embed { } } -export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed { - if (AppBskyEmbedImages.isView(embed)) { +export function parseEmbed( + /* + * 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 { type: 'images', view: embed, } - } else if (AppBskyEmbedGallery.isView(embed)) { + } else if (isType(app.bsky.embed.gallery.view, embed)) { return { type: 'gallery', view: embed, } - } else if (AppBskyEmbedExternal.isView(embed)) { + } else if (isType(app.bsky.embed.external.view, embed)) { return { type: 'link', view: embed, } - } else if (AppBskyEmbedVideo.isView(embed)) { + } else if (isType(app.bsky.embed.video.view, embed)) { return { type: 'video', view: embed, } - } else if (AppBskyEmbedRecord.isView(embed)) { + } else if (isType(app.bsky.embed.record.view, embed)) { return parseEmbedRecordView(embed) - } else if (AppBskyEmbedRecordWithMedia.isView(embed)) { + } else if (isType(app.bsky.embed.recordWithMedia.view, embed)) { return { type: 'post_with_media', view: parseEmbedRecordView(embed.record), diff --git a/src/types/bsky/profile.ts b/src/types/bsky/profile.ts index 12c8146ae1..f5a4b59cd3 100644 --- a/src/types/bsky/profile.ts +++ b/src/types/bsky/profile.ts @@ -1,9 +1,22 @@ 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 = + | app.bsky.actor.defs.ProfileViewBasic + | app.bsky.actor.defs.ProfileView + | app.bsky.actor.defs.ProfileViewDetailed + | chat.bsky.actor.defs.ProfileViewBasic | AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView | AppBskyActorDefs.ProfileViewDetailed diff --git a/src/types/bsky/starterPack.ts b/src/types/bsky/starterPack.ts index 0064e16bc6..5d4f4a5d76 100644 --- a/src/types/bsky/starterPack.ts +++ b/src/types/bsky/starterPack.ts @@ -1,11 +1,43 @@ -import {AppBskyGraphDefs} from '@atproto/api' +import {type AppBskyGraphDefs} from '@atproto/api' -export const isBasicView = AppBskyGraphDefs.isStarterPackViewBasic -export const isView = AppBskyGraphDefs.isStarterPackView +import {app} from '#/lexicons' + +/* + * 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 = + | app.bsky.graph.defs.StarterPackViewBasic + | app.bsky.graph.defs.StarterPackView | AppBskyGraphDefs.StarterPackViewBasic | AppBskyGraphDefs.StarterPackView