diff --git a/src/components/ComposerV2/store/__tests__/embeds.test.ts b/src/components/ComposerV2/store/__tests__/embeds.test.ts index 483400290d..960ece076b 100644 --- a/src/components/ComposerV2/store/__tests__/embeds.test.ts +++ b/src/components/ComposerV2/store/__tests__/embeds.test.ts @@ -18,6 +18,9 @@ jest.mock('#/state/session/agent', () => ({ import {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' +const EXTERNAL_URL = 'https://example.com' + function makeIdGenerator() { let i = 0 return () => `id-${++i}` @@ -59,13 +62,10 @@ function makeStore() { const fakePostView = (uri: string, cid: string) => ({uri, cid}) as unknown as AppBskyFeedDefs.PostView - const fakeGeneratorView = (uri: string, cid: string) => ({uri, cid}) as unknown as AppBskyFeedDefs.GeneratorView - const fakeListView = (uri: string, cid: string) => ({uri, cid}) as unknown as AppBskyGraphDefs.ListView - const fakeStarterPackView = (uri: string, cid: string) => ({uri, cid}) as unknown as AppBskyGraphDefs.StarterPackView @@ -99,39 +99,96 @@ const starterPackLink: ResolvedLink = { const externalLink: ResolvedLink = { type: 'external', - uri: 'https://example.com', + uri: EXTERNAL_URL, title: 'Example', description: 'A description', thumb: undefined, } -describe('addUri routes outcomes', () => { - test('post → quote (with view), embed cleared', async () => { +describe('addUri pre-classifies bsky post URLs to the quote slot', () => { + test('post URL → quote.pending → quote.resolved (with view)', async () => { const d = deferred() mockResolveLink.mockReturnValueOnce(d.promise) const store = makeStore() const root = rootId(store) - store.actions.addUri(root, 'https://bsky.app/post') - expect(store.getState().posts[root].embed?.state).toBe('pending') + store.actions.addUri(root, POST_URL) + + const pending = store.getState().posts[root].quote + if (pending?.state !== 'pending') throw new Error('expected pending') + expect(pending.uri).toBe(POST_URL) + expect(store.getState().posts[root].embed).toBeUndefined() d.resolve(postLink) await flushPromises() - const post = store.getState().posts[root] - expect(post.embed).toBeUndefined() - expect(post.quote).toEqual({ - uri: 'at://post', - cid: 'cp', - view: postLink.kind === 'post' ? postLink.view : undefined, - }) + const quote = store.getState().posts[root].quote + if (quote?.state !== 'resolved') throw new Error('expected resolved') + expect(quote.uri).toBe('at://post') + expect(quote.cid).toBe('cp') + expect(quote.view).toBe( + postLink.kind === 'post' ? postLink.view : undefined, + ) }) + test('addUri is a no-op when quote is already set', () => { + const store = makeStore() + const root = rootId(store) + store.actions.setQuoteEmbed(root, {uri: 'at://existing', cid: 'cx'}) + const before = store.getState() + store.actions.addUri(root, POST_URL) + expect(store.getState()).toBe(before) + expect(mockResolveLink).not.toHaveBeenCalled() + }) + + test('post URL with media still routes to quote (orthogonal to media)', async () => { + const d = deferred() + mockResolveLink.mockReturnValueOnce(d.promise) + const store = makeStore() + const root = rootId(store) + store.actions.addMedia(root, [ + {kind: 'image', uri: 'file:///a.jpg', width: 10, height: 10}, + ]) + store.actions.addUri(root, POST_URL) + d.resolve(postLink) + await flushPromises() + expect(store.getState().posts[root].quote?.state).toBe('resolved') + expect(store.getState().posts[root].media).toHaveLength(1) + }) + + test('post resolution failure produces quote.failed with bound retry()', async () => { + const d1 = deferred() + const d2 = deferred() + mockResolveLink + .mockReturnValueOnce(d1.promise) + .mockReturnValueOnce(d2.promise) + const store = makeStore() + const root = rootId(store) + store.actions.addUri(root, POST_URL) + d1.reject(new Error('post deleted')) + await flushPromises() + + const failed = store.getState().posts[root].quote + if (failed?.state !== 'failed') throw new Error('expected failed') + expect(failed.error).toContain('post deleted') + expect(typeof failed.retry).toBe('function') + + 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') + }) +}) + +describe('addUri pre-classifies non-post URLs to the embed slot', () => { test('feed → embed.feed', async () => { const d = deferred() mockResolveLink.mockReturnValueOnce(d.promise) const store = makeStore() const root = rootId(store) - store.actions.addUri(root, 'https://bsky.app/feed') + store.actions.addUri(root, EXTERNAL_URL) + expect(store.getState().posts[root].embed?.state).toBe('pending') d.resolve(feedLink) await flushPromises() expect(store.getState().posts[root].embed?.state).toBe('feed') @@ -142,7 +199,7 @@ describe('addUri routes outcomes', () => { mockResolveLink.mockReturnValueOnce(d.promise) const store = makeStore() const root = rootId(store) - store.actions.addUri(root, 'https://bsky.app/list') + store.actions.addUri(root, EXTERNAL_URL) d.resolve(listLink) await flushPromises() expect(store.getState().posts[root].embed?.state).toBe('list') @@ -153,7 +210,7 @@ describe('addUri routes outcomes', () => { mockResolveLink.mockReturnValueOnce(d.promise) const store = makeStore() const root = rootId(store) - store.actions.addUri(root, 'https://bsky.app/sp') + store.actions.addUri(root, EXTERNAL_URL) d.resolve(starterPackLink) await flushPromises() expect(store.getState().posts[root].embed?.state).toBe('starter-pack') @@ -164,7 +221,7 @@ describe('addUri routes outcomes', () => { mockResolveLink.mockReturnValueOnce(d.promise) const store = makeStore() const root = rootId(store) - store.actions.addUri(root, 'https://example.com') + store.actions.addUri(root, EXTERNAL_URL) d.resolve(externalLink) await flushPromises() const embed = store.getState().posts[root].embed @@ -172,55 +229,46 @@ describe('addUri routes outcomes', () => { expect(embed.title).toBe('Example') }) - test('post outcome dropped when quote is already set', async () => { + test('addUri is a no-op when embed has settled (resolved)', async () => { const d = deferred() mockResolveLink.mockReturnValueOnce(d.promise) const store = makeStore() const root = rootId(store) - store.actions.setQuoteEmbed(root, {uri: 'at://existing', cid: 'cx'}) - store.actions.addUri(root, 'https://bsky.app/post') - d.resolve(postLink) - await flushPromises() - expect(store.getState().posts[root].quote).toEqual({ - uri: 'at://existing', - cid: 'cx', - }) - expect(store.getState().posts[root].embed).toBeUndefined() - }) - - test('non-post outcome with media present is silently dropped', async () => { - const d = deferred() - mockResolveLink.mockReturnValueOnce(d.promise) - const store = makeStore() - const root = rootId(store) - store.actions.addMedia(root, [ - {kind: 'image', uri: 'file:///a.jpg', width: 10, height: 10}, - ]) - store.actions.addUri(root, 'https://example.com') + store.actions.addUri(root, EXTERNAL_URL) d.resolve(externalLink) await flushPromises() - expect(store.getState().posts[root].embed).toBeUndefined() - expect(store.getState().posts[root].media).toHaveLength(1) + expect(store.getState().posts[root].embed?.state).toBe('external') + + store.actions.addUri(root, 'https://other.example') + // Settled slot blocks the second addUri. + expect(mockResolveLink).toHaveBeenCalledTimes(1) + expect(store.getState().posts[root].embed?.state).toBe('external') }) - test('post outcome with media present routes to quote', async () => { - const d = deferred() - mockResolveLink.mockReturnValueOnce(d.promise) + test('addUri replaces a pending embed (e.g. user pastes a different URL)', () => { + mockResolveLink.mockReturnValue(new Promise(() => {})) + const store = makeStore() + const root = rootId(store) + store.actions.addUri(root, EXTERNAL_URL) + store.actions.addUri(root, 'https://other.example') + expect(mockResolveLink).toHaveBeenCalledTimes(2) + const embed = store.getState().posts[root].embed + if (embed?.state !== 'pending') throw new Error('expected pending') + expect(embed.uri).toBe('https://other.example') + }) + + test('addUri is a no-op when media is set (target is embed)', () => { const store = makeStore() const root = rootId(store) store.actions.addMedia(root, [ {kind: 'image', uri: 'file:///a.jpg', width: 10, height: 10}, ]) - store.actions.addUri(root, 'https://bsky.app/post') - d.resolve(postLink) - await flushPromises() - expect(store.getState().posts[root].quote?.uri).toBe('at://post') - expect(store.getState().posts[root].media).toHaveLength(1) + store.actions.addUri(root, EXTERNAL_URL) + expect(store.getState().posts[root].embed).toBeUndefined() + expect(mockResolveLink).not.toHaveBeenCalled() }) -}) -describe('addUri failure and retry', () => { - test('rejection produces a failed embed with a bound retry()', async () => { + test('embed resolution failure produces embed.failed with bound retry()', async () => { const d1 = deferred() const d2 = deferred() mockResolveLink @@ -228,7 +276,7 @@ describe('addUri failure and retry', () => { .mockReturnValueOnce(d2.promise) const store = makeStore() const root = rootId(store) - store.actions.addUri(root, 'https://example.com') + store.actions.addUri(root, EXTERNAL_URL) d1.reject(new Error('network down')) await flushPromises() @@ -245,35 +293,42 @@ describe('addUri failure and retry', () => { }) }) -describe('addUri cancellation', () => { - test('a second addUri invalidates the first response', async () => { - const d1 = deferred() - const d2 = deferred() - mockResolveLink - .mockReturnValueOnce(d1.promise) - .mockReturnValueOnce(d2.promise) - const store = makeStore() - const root = rootId(store) - store.actions.addUri(root, 'https://a.example') - store.actions.addUri(root, 'https://b.example') - d1.resolve(externalLink) - await flushPromises() - const pending = store.getState().posts[root].embed - if (pending?.state !== 'pending') throw new Error('expected pending') - expect(pending.uri).toBe('https://b.example') - }) - +describe('addUri cancellation by ignoring stale responses', () => { test('removeEmbed before the response lands keeps embed undefined', async () => { const d = deferred() mockResolveLink.mockReturnValueOnce(d.promise) const store = makeStore() const root = rootId(store) - store.actions.addUri(root, 'https://example.com') + store.actions.addUri(root, EXTERNAL_URL) store.actions.removeEmbed(root) d.resolve(externalLink) await flushPromises() expect(store.getState().posts[root].embed).toBeUndefined() }) + + test('removeQuoteEmbed before the response lands keeps quote undefined', async () => { + const d = deferred() + mockResolveLink.mockReturnValueOnce(d.promise) + const store = makeStore() + const root = rootId(store) + store.actions.addUri(root, POST_URL) + store.actions.removeQuoteEmbed(root) + d.resolve(postLink) + await flushPromises() + expect(store.getState().posts[root].quote).toBeUndefined() + }) + + test('removeEmbed does not invalidate an in-flight quote resolution', async () => { + const d = deferred() + mockResolveLink.mockReturnValueOnce(d.promise) + const store = makeStore() + const root = rootId(store) + store.actions.addUri(root, POST_URL) + store.actions.removeEmbed(root) // unrelated slot + d.resolve(postLink) + await flushPromises() + expect(store.getState().posts[root].quote?.state).toBe('resolved') + }) }) describe('setQuoteEmbed / removeQuoteEmbed', () => { @@ -281,10 +336,10 @@ describe('setQuoteEmbed / removeQuoteEmbed', () => { const store = makeStore() const root = rootId(store) store.actions.setQuoteEmbed(root, {uri: 'at://x', cid: 'c'}) - expect(store.getState().posts[root].quote).toEqual({ - uri: 'at://x', - cid: 'c', - }) + const quote = store.getState().posts[root].quote + if (quote?.state !== 'resolved') throw new Error('expected resolved') + expect(quote.uri).toBe('at://x') + expect(quote.cid).toBe('c') expect(store.getState().isDirty).toBe(true) }) diff --git a/src/components/ComposerV2/store/index.ts b/src/components/ComposerV2/store/index.ts index af1481dcba..9d44c6a71a 100644 --- a/src/components/ComposerV2/store/index.ts +++ b/src/components/ComposerV2/store/index.ts @@ -1,8 +1,11 @@ -import {type AtpAgent} from '@atproto/api' +import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api' import {nanoid} from 'nanoid/non-secure' import {type resolveLink} from '#/lib/api/resolve' -import {startUriResolution} from '#/components/ComposerV2/store/linkResolution' +import { + type LinkResolutionOutcome, + startUriResolution, +} from '#/components/ComposerV2/store/linkResolution' import type * as types from '#/components/ComposerV2/store/types' import { startImageUpload, @@ -11,6 +14,7 @@ import { } from '#/components/ComposerV2/store/uploads' import {buildPostMediaItem} from '#/components/ComposerV2/store/utils/buildPostMediaItem' import {buildThreadPost} from '#/components/ComposerV2/store/utils/buildThreadPost' +import {classifyUriTarget} from '#/components/ComposerV2/store/utils/classifyUriTarget' import {computePostMediaSelectionsRemaining} from '#/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining' import {filterMediaInputs} from '#/components/ComposerV2/store/utils/filterMediaInputs' @@ -43,17 +47,18 @@ export function createThreadStore(options: { const uploadTasks = new Map() /** - * Generation counter per post for embed link resolution. Cancellation is - * implemented by ignoring stale resolution callbacks: every action that - * starts or invalidates a resolution (addUri, removeEmbed, removePost, - * destroy) bumps the post's gen, and the worker callback compares its - * captured gen against the current value before writing to state. + * Per-slot generation counters. Quote and embed are orthogonal slots, so + * each has its own counter; cancelling one doesn't invalidate the other. + * Every action that starts or invalidates a resolution for a slot bumps + * that slot's gen, and the worker callback compares its captured gen + * against the current value before writing to state. */ + const quoteGenByPost = new Map() const embedGenByPost = new Map() - function bumpEmbedGen(postId: string): number { - const next = (embedGenByPost.get(postId) ?? 0) + 1 - embedGenByPost.set(postId, next) + function bumpGen(map: Map, postId: string): number { + const next = (map.get(postId) ?? 0) + 1 + map.set(postId, next) return next } @@ -135,9 +140,11 @@ export function createThreadStore(options: { if (!(postId in s.posts)) return null // Cancel any in-flight uploads for media on this post before dropping it. for (const m of s.posts[postId].media) cancelUploadTask(m.id) - // Bump (and drop) the embed gen so a stale resolution callback for + // Bump (and drop) both gens so any stale resolution callbacks for // this post can never write back into state. - bumpEmbedGen(postId) + bumpGen(quoteGenByPost, postId) + bumpGen(embedGenByPost, postId) + quoteGenByPost.delete(postId) embedGenByPost.delete(postId) delete s.posts[postId] s.isDirty = true @@ -284,26 +291,64 @@ export function createThreadStore(options: { } /** - * Generic URI handler. Sets `embed` to `pending` synchronously, kicks off - * `resolveLink`, and routes the outcome: - * - Bluesky post -> goes to the post's `quote` field (clears embed). If - * `quote` is already set, the new outcome is dropped silently to - * preserve the user's prior selection. - * - Feed / list / starter-pack / external -> stays on `embed`, unless the - * post has media in which case it's dropped silently (embed cleared). - * - Failure -> embed is set to a `failed` state with a bound `retry()` that - * re-runs `addUri` for the same URI. + * Generic URI handler. Pre-classifies the URI from its URL pattern to + * decide which slot the eventual data will land in: * - * Cancellation is gen-based: any later addUri / removeEmbed / removePost / - * destroy invalidates this call's outcome before it can land. + * - Bluesky post URL -> `quote` slot (coexists with media). + * - Anything else (feed / list / starter-pack / external) -> `embed` slot + * (mutually exclusive with media). + * + * Conflict checks happen synchronously based on the target slot: + * - If targeting quote and quote is already set -> no-op (preserves prior). + * - If targeting embed and embed is already set -> no-op. + * - If targeting embed and media is set -> no-op. + * + * Otherwise, pending state is written to the target slot synchronously and + * `resolveLink` runs in the background. The outcome lands in the same slot + * (resolved or failed). Cancellation is per-slot gen-based. */ function addUri(postId: string, uri: string) { - if (!(postId in state.posts)) return - const gen = bumpEmbedGen(postId) + const post = state.posts[postId] + if (!post) return + + const target = classifyUriTarget(uri) + + if (target === 'quote') { + // No-op only when the slot has a settled value. Pending and failed + // states are replaceable (failed.retry() relies on this). + if (post.quote?.state === 'resolved') return + const gen = bumpGen(quoteGenByPost, postId) + mutateState(s => { + const p = s.posts[postId] + if (!p) return null + s.posts[postId] = setPostQuote(p, {state: 'pending', uri}) + s.isDirty = true + return s + }) + startUriResolution({ + postId, + uri, + resolveLink: resolveLinkOverride, + onResolve: handleQuoteResolution(gen, uri), + }) + return + } + + // target === 'embed' + // Same rule as quote: settled values block; pending / failed are + // replaceable (so retry() works on a failed embed). + const embedSettled = + post.embed !== undefined && + post.embed.state !== 'pending' && + post.embed.state !== 'failed' + if (embedSettled) return + if (post.media.length > 0) return + + const gen = bumpGen(embedGenByPost, postId) mutateState(s => { - const post = s.posts[postId] - if (!post) return null - s.posts[postId] = setPostEmbed(post, {state: 'pending', uri}) + const p = s.posts[postId] + if (!p) return null + s.posts[postId] = setPostEmbed(p, {state: 'pending', uri}) s.isDirty = true return s }) @@ -311,51 +356,75 @@ export function createThreadStore(options: { postId, uri, resolveLink: resolveLinkOverride, - onResolve: handleEmbedResolution(gen), + onResolve: handleEmbedResolution(gen, uri), }) } - function handleEmbedResolution(gen: number) { - return (postId: string, outcome: types.LinkResolutionOutcome) => { + function handleQuoteResolution(gen: number, uri: string) { + return (postId: string, outcome: LinkResolutionOutcome) => { if (destroyed) return - if (embedGenByPost.get(postId) !== gen) return - if (outcome.kind === 'post') { - mutateState(s => { - const post = s.posts[postId] - if (!post) return null - if (post.quote !== undefined) { - // Quote already set: drop the post outcome, just clear pending. - s.posts[postId] = setPostEmbed(post, undefined) - return s - } - s.posts[postId] = setPostEmbed( - { - ...post, - quote: { - uri: outcome.record.uri, - cid: outcome.record.cid, - view: outcome.view, - }, - }, - undefined, - ) - return s - }) - return - } - // outcome.kind === 'embed' - const post = state.posts[postId] - if (!post) return - if (post.media.length > 0) { - // Embed-vs-media collision: silent drop, clear pending. + if (quoteGenByPost.get(postId) !== gen) return + + // 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. + if (outcome.kind !== 'post') { + const error = + outcome.embed.state === 'failed' + ? outcome.embed.error + : 'Could not resolve post' + const failed: types.PostEmbedQuote = { + state: 'failed', + uri, + error, + retry: () => addUri(postId, uri), + } mutateState(s => { const p = s.posts[postId] if (!p) return null - s.posts[postId] = setPostEmbed(p, undefined) + s.posts[postId] = setPostQuote(p, failed) return s }) return } + mutateState(s => { + const p = s.posts[postId] + if (!p) return null + s.posts[postId] = setPostQuote(p, { + state: 'resolved', + uri: outcome.record.uri, + cid: outcome.record.cid, + view: outcome.view, + }) + return s + }) + } + } + + function handleEmbedResolution(gen: number, uri: string) { + return (postId: string, outcome: LinkResolutionOutcome) => { + if (destroyed) return + if (embedGenByPost.get(postId) !== gen) return + + // Pre-classification said this was a non-post URL. If resolveLink + // surprises us with a post outcome, treat as failure rather than + // silently moving slots. + if (outcome.kind === 'post') { + const failed: types.PostEmbed = { + state: 'failed', + uri, + error: 'Unexpected post outcome for non-post URL', + retry: () => addUri(postId, uri), + } + mutateState(s => { + const p = s.posts[postId] + if (!p) return null + s.posts[postId] = setPostEmbed(p, failed) + return s + }) + return + } + const embed = outcome.embed const stored: types.PostEmbed = embed.state === 'failed' @@ -371,7 +440,7 @@ export function createThreadStore(options: { } function removeEmbed(postId: string) { - bumpEmbedGen(postId) + bumpGen(embedGenByPost, postId) mutateState(s => { const post = s.posts[postId] if (!post) return null @@ -382,22 +451,37 @@ export function createThreadStore(options: { }) } - function setQuoteEmbed(postId: string, quote: types.PostEmbedQuote) { + /** + * Direct setter for an already-resolved quote. Used for draft restore and + * any UI flow that already has the post ref+view in hand. Bumps the quote + * gen so any in-flight resolution is invalidated. + */ + function setQuoteEmbed( + postId: string, + ref: {uri: string; cid: string; view?: AppBskyFeedDefs.PostView}, + ) { + bumpGen(quoteGenByPost, postId) mutateState(s => { const post = s.posts[postId] if (!post) return null - s.posts[postId] = {...post, quote} + s.posts[postId] = setPostQuote(post, { + state: 'resolved', + uri: ref.uri, + cid: ref.cid, + view: ref.view, + }) s.isDirty = true return s }) } function removeQuoteEmbed(postId: string) { + bumpGen(quoteGenByPost, postId) mutateState(s => { const post = s.posts[postId] if (!post) return null if (post.quote === undefined) return null - s.posts[postId] = {...post, quote: undefined} + s.posts[postId] = setPostQuote(post, undefined) s.isDirty = true return s }) @@ -479,6 +563,17 @@ export function createThreadStore(options: { } } + /** + * Single chokepoint for replacing a post's quote slot. Quote is + * orthogonal to media so no selectionsRemaining recomputation is needed. + */ + function setPostQuote( + post: types.ThreadPost, + quote: types.PostEmbedQuote | undefined, + ): types.ThreadPost { + return {...post, quote} + } + return { actions: { setPostText, @@ -500,6 +595,7 @@ export function createThreadStore(options: { destroyed = true for (const task of uploadTasks.values()) task.cancel() uploadTasks.clear() + quoteGenByPost.clear() embedGenByPost.clear() }, getState() { diff --git a/src/components/ComposerV2/store/linkResolution.ts b/src/components/ComposerV2/store/linkResolution.ts index ec5f54ce6f..3e6d54fc8b 100644 --- a/src/components/ComposerV2/store/linkResolution.ts +++ b/src/components/ComposerV2/store/linkResolution.ts @@ -11,13 +11,62 @@ * The `__resolveLink` test seam (passed through from createThreadStore) * lets tests inject a controllable promise. */ +import { + type AppBskyFeedDefs, + type AppBskyGraphDefs, + type ComAtprotoRepoStrongRef, +} from '@atproto/api' + import { 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 LinkResolutionOutcome} from './types' + +/** + * What the worker (or a test) reports back about a resolved URI. `pending` + * is not part of this type because the worker only emits terminal outcomes; + * the synchronous pending state is set by the store itself before the + * worker runs. + */ +export type EmbedResolution = + | {state: 'failed'; uri: string; error: string} + | { + state: 'external' + uri: string + title: string + description: string + thumb: ComposerImage | undefined + } + | { + state: 'feed' + record: ComAtprotoRepoStrongRef.Main + view: AppBskyFeedDefs.GeneratorView + } + | { + state: 'list' + record: ComAtprotoRepoStrongRef.Main + view: AppBskyGraphDefs.ListView + } + | { + state: 'starter-pack' + record: ComAtprotoRepoStrongRef.Main + view: AppBskyGraphDefs.StarterPackView + } + +/** + * Worker output for a single URI. The store routes `kind: 'post'` to the + * post's `quote` field and everything else to the `embed` field. + */ +export type LinkResolutionOutcome = + | { + kind: 'post' + record: ComAtprotoRepoStrongRef.Main + view: AppBskyFeedDefs.PostView + } + | {kind: 'embed'; embed: EmbedResolution} export type StartUriResolutionOptions = { postId: string diff --git a/src/components/ComposerV2/store/types.ts b/src/components/ComposerV2/store/types.ts index ccd81f0de9..76095750f3 100644 --- a/src/components/ComposerV2/store/types.ts +++ b/src/components/ComposerV2/store/types.ts @@ -83,47 +83,16 @@ export type PostEmbedMedia = | (PostEmbedMediaGif & {kind: 'gif'}) /** - * What a link-resolution reporter (the worker, or a test) sends in. Failed - * inputs carry just the error string; the store wraps the failure with a - * bound `retry()` method when it stores the status. - * - * `pending` is included so the store can construct the initial pending state - * with the same type vocabulary, but the worker never emits `pending` - it - * only emits terminal outcomes (the post-resolution variants and `failed`). - */ -export type EmbedResolution = - | {state: 'pending'; uri: string} - | {state: 'failed'; uri: string; error: string} - | { - state: 'external' - uri: string - title: string - description: string - thumb: ComposerImage | undefined - } - | { - state: 'feed' - record: ComAtprotoRepoStrongRef.Main - view: AppBskyFeedDefs.GeneratorView - } - | { - state: 'list' - record: ComAtprotoRepoStrongRef.Main - view: AppBskyGraphDefs.ListView - } - | { - state: 'starter-pack' - record: ComAtprotoRepoStrongRef.Main - view: AppBskyGraphDefs.StarterPackView - } - -/** - * What's stored on a post's `embed` field. Mirrors PostMediaUploadStatus' - * shape: the failed variant has a bound `retry()` so UI can call it directly - * without having to look up the post id. + * 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. * * Note: `retry` is a function reference and won't survive JSON serialization. * On restore (OS-resume / draft load), the store re-attaches it. + * + * (Worker-side input and outcome types live in linkResolution.ts.) */ export type PostEmbed = | {state: 'pending'; uri: string} @@ -152,23 +121,24 @@ export type PostEmbed = } /** - * Worker output for an `addUri` call. The store routes `kind: 'post'` to the - * post's `quote` field and everything else to the `embed` field. + * What's stored on a post's `quote` field. Mirrors `PostEmbed`'s shape: the + * pending variant is set synchronously by addUri while the post is being + * resolved; the resolved variant lands when the worker reports back; the + * failed variant carries a bound `retry()`. + * + * `view` is optional on the resolved variant because `setQuoteEmbed` (used + * for direct programmatic insertion, e.g. draft restore) may not have a + * hydrated post view to hand. */ -export type LinkResolutionOutcome = +export type PostEmbedQuote = + | {state: 'pending'; uri: string} + | {state: 'failed'; uri: string; error: string; retry: () => void} | { - kind: 'post' - record: ComAtprotoRepoStrongRef.Main - view: AppBskyFeedDefs.PostView + state: 'resolved' + uri: string + cid: string + view?: AppBskyFeedDefs.PostView } - | {kind: 'embed'; embed: Exclude} - -export type PostEmbedQuote = { - uri: string - cid: string - /** Hydrated post view; populated when addUri resolves a post. */ - view?: AppBskyFeedDefs.PostView -} export type ThreadPost = { text: string diff --git a/src/components/ComposerV2/store/utils/classifyUriTarget.ts b/src/components/ComposerV2/store/utils/classifyUriTarget.ts new file mode 100644 index 0000000000..51c5edde20 --- /dev/null +++ b/src/components/ComposerV2/store/utils/classifyUriTarget.ts @@ -0,0 +1,17 @@ +import {isBskyPostUrl} from '#/lib/strings/url-helpers' + +/** + * Which slot on a post a given URI is destined for. Bluesky post URLs go to + * the `quote` slot; everything else (feed/list/starter-pack records and + * generic external links) goes to the `embed` slot. + * + * Used by addUri to set the pending state on the correct slot synchronously, + * surface conflict no-ops upfront, and avoid a "data moves between slots" + * race after async resolution. + */ +export type EmbedTargetSlot = 'quote' | 'embed' + +export function classifyUriTarget(uri: string): EmbedTargetSlot { + if (isBskyPostUrl(uri)) return 'quote' + return 'embed' +}