diff --git a/src/components/ComposerV2/store/__tests__/embeds.test.ts b/src/components/ComposerV2/store/__tests__/embeds.test.ts index 960ece076b..035bcfa12e 100644 --- a/src/components/ComposerV2/store/__tests__/embeds.test.ts +++ b/src/components/ComposerV2/store/__tests__/embeds.test.ts @@ -7,15 +7,28 @@ import {beforeEach, describe, expect, jest, test} from '@jest/globals' // Avoid pulling the UI module chain (gallery → media picker → ALF) into the // test environment. Tests inject `__resolveLink` directly, so the real -// implementation is never invoked. -jest.mock('#/lib/api/resolve', () => ({ - resolveLink: jest.fn(), -})) +// implementation is never invoked. We do mirror EmbeddingDisabledError so +// `instanceof` checks in parseErrorCode still match the imported class. +jest.mock('#/lib/api/resolve', () => { + class EmbeddingDisabledError extends Error { + constructor() { + super('Embedding is disabled for this record') + } + } + return { + resolveLink: jest.fn(), + EmbeddingDisabledError, + } +}) jest.mock('#/state/session/agent', () => ({ createPublicAgent: jest.fn(() => ({})), })) -import {type ResolvedLink, type resolveLink} from '#/lib/api/resolve' +import { + EmbeddingDisabledError, + type ResolvedLink, + type resolveLink, +} from '#/lib/api/resolve' import {createThreadStore} from '#/components/ComposerV2/store' const POST_URL = 'https://bsky.app/profile/test.bsky.social/post/abc' @@ -170,15 +183,31 @@ describe('addUri pre-classifies bsky post URLs to the quote slot', () => { const failed = store.getState().posts[root].quote if (failed?.state !== 'failed') throw new Error('expected failed') expect(failed.error).toContain('post deleted') + expect(failed.code).toBe('unknown') expect(typeof failed.retry).toBe('function') - failed.retry() + failed.retry?.() expect(store.getState().posts[root].quote?.state).toBe('pending') d2.resolve(postLink) await flushPromises() expect(store.getState().posts[root].quote?.state).toBe('resolved') }) + + test('embedding-disabled error is non-retryable (no retry on failed)', async () => { + const d = deferred() + mockResolveLink.mockReturnValueOnce(d.promise) + const store = makeStore() + const root = rootId(store) + store.actions.addUri(root, POST_URL) + d.reject(new EmbeddingDisabledError()) + await flushPromises() + + const failed = store.getState().posts[root].quote + if (failed?.state !== 'failed') throw new Error('expected failed') + expect(failed.code).toBe('embedding-disabled') + expect(failed.retry).toBeUndefined() + }) }) describe('addUri pre-classifies non-post URLs to the embed slot', () => { @@ -283,8 +312,9 @@ describe('addUri pre-classifies non-post URLs to the embed slot', () => { const failed = store.getState().posts[root].embed if (failed?.state !== 'failed') throw new Error('expected failed') expect(failed.error).toContain('network down') + expect(failed.code).toBe('unknown') - failed.retry() + failed.retry?.() expect(store.getState().posts[root].embed?.state).toBe('pending') d2.resolve(externalLink) diff --git a/src/components/ComposerV2/store/index.ts b/src/components/ComposerV2/store/index.ts index 9d44c6a71a..8ee9803e88 100644 --- a/src/components/ComposerV2/store/index.ts +++ b/src/components/ComposerV2/store/index.ts @@ -367,8 +367,12 @@ export function createThreadStore(options: { // Pre-classification said this was a post URL. If resolveLink disagrees // (deleted post, embedding disabled, network error, etc.), surface as - // failed in the quote slot. + // failed in the quote slot. Non-retryable failure codes (e.g. + // embedding-disabled) get a failed state with no `retry()`; the user + // has to remove the embed manually. if (outcome.kind !== 'post') { + const code: types.LinkResolutionFailureCode = + outcome.embed.state === 'failed' ? outcome.embed.code : 'unknown' const error = outcome.embed.state === 'failed' ? outcome.embed.error @@ -377,7 +381,11 @@ export function createThreadStore(options: { state: 'failed', uri, error, - retry: () => addUri(postId, uri), + code, + retry: + code === 'embedding-disabled' + ? undefined + : () => addUri(postId, uri), } mutateState(s => { const p = s.posts[postId] @@ -414,6 +422,7 @@ export function createThreadStore(options: { state: 'failed', uri, error: 'Unexpected post outcome for non-post URL', + code: 'unknown', retry: () => addUri(postId, uri), } mutateState(s => { @@ -428,7 +437,13 @@ export function createThreadStore(options: { const embed = outcome.embed const stored: types.PostEmbed = embed.state === 'failed' - ? {...embed, retry: () => addUri(postId, embed.uri)} + ? { + ...embed, + retry: + embed.code === 'embedding-disabled' + ? undefined + : () => addUri(postId, embed.uri), + } : embed mutateState(s => { const p = s.posts[postId] diff --git a/src/components/ComposerV2/store/linkResolution.ts b/src/components/ComposerV2/store/linkResolution.ts index 3e6d54fc8b..f83dad3357 100644 --- a/src/components/ComposerV2/store/linkResolution.ts +++ b/src/components/ComposerV2/store/linkResolution.ts @@ -18,12 +18,14 @@ import { } from '@atproto/api' import { + EmbeddingDisabledError, type ResolvedLink, type resolveLink as defaultResolveLink, } from '#/lib/api/resolve' import {resolveLink as importedResolveLink} from '#/lib/api/resolve' import {type ComposerImage} from '#/state/gallery' import {createPublicAgent} from '#/state/session/agent' +import {type LinkResolutionFailureCode} from './types' /** * What the worker (or a test) reports back about a resolved URI. `pending` @@ -32,7 +34,12 @@ import {createPublicAgent} from '#/state/session/agent' * worker runs. */ export type EmbedResolution = - | {state: 'failed'; uri: string; error: string} + | { + state: 'failed' + uri: string + error: string + code: LinkResolutionFailureCode + } | { state: 'external' uri: string @@ -87,11 +94,23 @@ export function startUriResolution(opts: StartUriResolutionOptions): void { state: 'failed', uri: opts.uri, error: String((err && (err as Error).message) ?? err), + code: parseErrorCode(err), }, }), ) } +/** + * Classify a thrown error into a stable failure code that drives UI + * behavior. Today we only special-case EmbeddingDisabledError (which + * `resolveLink` throws when fetching a post the author has marked + * non-embeddable); everything else falls into 'unknown' and is retryable. + */ +export function parseErrorCode(err: unknown): LinkResolutionFailureCode { + if (err instanceof EmbeddingDisabledError) return 'embedding-disabled' + return 'unknown' +} + function mapResolvedLink(link: ResolvedLink): LinkResolutionOutcome { if (link.type === 'external') { return { diff --git a/src/components/ComposerV2/store/types.ts b/src/components/ComposerV2/store/types.ts index 76095750f3..d99d55ce6d 100644 --- a/src/components/ComposerV2/store/types.ts +++ b/src/components/ComposerV2/store/types.ts @@ -83,11 +83,21 @@ export type PostEmbedMedia = | (PostEmbedMediaGif & {kind: 'gif'}) /** - * What's stored on a post's `embed` field. The failed variant has a bound - * `retry()` so UI can call it directly without having to look up the post - * id. The pending variant is set synchronously by addUri while resolution - * is in flight; the resolved variants (external/feed/list/starter-pack) - * land when the worker reports back. + * Coarse classification of why a link resolution failed. Drives UI + * affordances — for example, `embedding-disabled` is a permanent rejection + * (embedding the post is forbidden by the author), so the failed variant + * does not carry a `retry()`. Anything else falls under `unknown` and is + * retryable. + */ +export type LinkResolutionFailureCode = 'embedding-disabled' | 'unknown' + +/** + * What's stored on a post's `embed` field. The failed variant carries a + * bound `retry()` for retryable codes; for permanent failures (e.g. + * `embedding-disabled`) `retry` is omitted so UI can detect that case and + * surface a non-retryable message. The pending variant is set synchronously + * by addUri while resolution is in flight; the resolved variants + * (external/feed/list/starter-pack) land when the worker reports back. * * Note: `retry` is a function reference and won't survive JSON serialization. * On restore (OS-resume / draft load), the store re-attaches it. @@ -96,7 +106,13 @@ export type PostEmbedMedia = */ export type PostEmbed = | {state: 'pending'; uri: string} - | {state: 'failed'; uri: string; error: string; retry: () => void} + | { + state: 'failed' + uri: string + error: string + code: LinkResolutionFailureCode + retry?: () => void + } | { state: 'external' uri: string @@ -132,7 +148,13 @@ export type PostEmbed = */ export type PostEmbedQuote = | {state: 'pending'; uri: string} - | {state: 'failed'; uri: string; error: string; retry: () => void} + | { + state: 'failed' + uri: string + error: string + code: LinkResolutionFailureCode + retry?: () => void + } | { state: 'resolved' uri: string