From a1eb8f9765b1d41bfa1edc877f64d3229acf72aa Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 1 May 2026 16:45:02 +0100 Subject: [PATCH] checkopoint full picture, time to wite it up --- .../ComposerV2/store/__tests__/embeds.test.ts | 103 ++++++++++++++++++ .../ComposerV2/store/__tests__/media.test.ts | 31 ++++++ src/components/ComposerV2/store/index.ts | 77 ++++++++++++- src/components/ComposerV2/store/types.ts | 9 +- src/components/ComposerV2/store/uploads.ts | 10 +- .../ComposerV2/store/utils/buildThreadPost.ts | 2 +- .../computePostMediaSelectionsRemaining.ts | 25 ++++- .../store/utils/filterMediaInputs.ts | 12 +- 8 files changed, 242 insertions(+), 27 deletions(-) create mode 100644 src/components/ComposerV2/store/__tests__/embeds.test.ts diff --git a/src/components/ComposerV2/store/__tests__/embeds.test.ts b/src/components/ComposerV2/store/__tests__/embeds.test.ts new file mode 100644 index 0000000000..f99ff44472 --- /dev/null +++ b/src/components/ComposerV2/store/__tests__/embeds.test.ts @@ -0,0 +1,103 @@ +import {type AtpAgent} from '@atproto/api' +import {describe, expect, test} from '@jest/globals' + +import {createThreadStore} from '#/components/ComposerV2/store' + +function makeIdGenerator() { + let i = 0 + return () => `id-${++i}` +} + +const agent = {} as AtpAgent + +function rootId(store: ReturnType) { + return Object.keys(store.getState().posts)[0] +} + +describe('setExternalEmbed / removeExternalEmbed', () => { + test('sets the external embed and marks state dirty', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const root = rootId(store) + store.actions.setExternalEmbed(root, {uri: 'https://example.com'}) + expect(store.getState().posts[root].external).toEqual({ + uri: 'https://example.com', + }) + expect(store.getState().isDirty).toBe(true) + }) + + test('replaces an existing external embed', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const root = rootId(store) + store.actions.setExternalEmbed(root, {uri: 'https://a.example'}) + store.actions.setExternalEmbed(root, {uri: 'https://b.example'}) + expect(store.getState().posts[root].external?.uri).toBe('https://b.example') + }) + + test('removeExternalEmbed clears the external embed', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const root = rootId(store) + store.actions.setExternalEmbed(root, {uri: 'https://example.com'}) + store.actions.removeExternalEmbed(root) + expect(store.getState().posts[root].external).toBeUndefined() + }) + + test('removeExternalEmbed is a no-op when nothing is set', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const before = store.getState() + store.actions.removeExternalEmbed(rootId(store)) + expect(store.getState()).toBe(before) + }) + + test('setExternalEmbed is a no-op when post id is unknown', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const before = store.getState() + store.actions.setExternalEmbed('does-not-exist', {uri: 'https://x'}) + expect(store.getState()).toBe(before) + }) +}) + +describe('setQuoteEmbed / removeQuoteEmbed', () => { + test('sets the quote embed and marks state dirty', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + 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', + }) + expect(store.getState().isDirty).toBe(true) + }) + + test('replaces an existing quote embed', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const root = rootId(store) + store.actions.setQuoteEmbed(root, {uri: 'at://a', cid: 'ca'}) + store.actions.setQuoteEmbed(root, {uri: 'at://b', cid: 'cb'}) + expect(store.getState().posts[root].quote).toEqual({ + uri: 'at://b', + cid: 'cb', + }) + }) + + test('removeQuoteEmbed clears the quote', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const root = rootId(store) + store.actions.setQuoteEmbed(root, {uri: 'at://x', cid: 'c'}) + store.actions.removeQuoteEmbed(root) + expect(store.getState().posts[root].quote).toBeUndefined() + }) + + test('removeQuoteEmbed is a no-op when nothing is set', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const before = store.getState() + store.actions.removeQuoteEmbed(rootId(store)) + expect(store.getState()).toBe(before) + }) + + test('setQuoteEmbed is a no-op when post id is unknown', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const before = store.getState() + store.actions.setQuoteEmbed('does-not-exist', {uri: 'at://x', cid: 'c'}) + expect(store.getState()).toBe(before) + }) +}) diff --git a/src/components/ComposerV2/store/__tests__/media.test.ts b/src/components/ComposerV2/store/__tests__/media.test.ts index 598b1755df..49e10d8841 100644 --- a/src/components/ComposerV2/store/__tests__/media.test.ts +++ b/src/components/ComposerV2/store/__tests__/media.test.ts @@ -246,6 +246,16 @@ describe('addMedia respects existing media on the post', () => { expect(ids).toEqual([]) expect(store.getState()).toBe(before) }) + + test('is a no-op when the post has an external link card', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const root = rootId(store) + store.actions.setExternalEmbed(root, {uri: 'https://example.com'}) + const before = store.getState() + const ids = store.actions.addMedia(root, [imageInput]) + expect(ids).toEqual([]) + expect(store.getState()).toBe(before) + }) }) describe('selectionsRemaining flags on the post', () => { @@ -302,6 +312,27 @@ describe('selectionsRemaining flags on the post', () => { expect(post.videoSelectionsRemaining).toBe(1) expect(post.gifSelectionsRemaining).toBe(1) }) + + test('an external link card locks all three counters to 0', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const root = rootId(store) + store.actions.setExternalEmbed(root, {uri: 'https://example.com'}) + const post = store.getState().posts[root] + expect(post.imageSelectionsRemaining).toBe(0) + expect(post.videoSelectionsRemaining).toBe(0) + expect(post.gifSelectionsRemaining).toBe(0) + }) + + test('removing the external link card restores capacity', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const root = rootId(store) + store.actions.setExternalEmbed(root, {uri: 'https://example.com'}) + store.actions.removeExternalEmbed(root) + const post = store.getState().posts[root] + expect(post.imageSelectionsRemaining).toBe(4) + expect(post.videoSelectionsRemaining).toBe(1) + expect(post.gifSelectionsRemaining).toBe(1) + }) }) describe('removeMedia', () => { diff --git a/src/components/ComposerV2/store/index.ts b/src/components/ComposerV2/store/index.ts index 1eb178e111..39a8f08bc3 100644 --- a/src/components/ComposerV2/store/index.ts +++ b/src/components/ComposerV2/store/index.ts @@ -136,6 +136,11 @@ export function createThreadStore(options: { if (!(postId in state.posts)) return undefined if (inputs.length === 0) return [] + // External link cards are mutually exclusive with media. This rule is + // permanent (unlike the kind/cap rules in filterMediaInputs) so it lives + // here at the action boundary rather than inside the filter helper. + if (state.posts[postId].external !== undefined) return [] + const accepted = filterMediaInputs(state.posts[postId].media, inputs) if (accepted.length === 0) return [] @@ -253,9 +258,51 @@ export function createThreadStore(options: { ) } + function setExternalEmbed(postId: string, external: types.PostEmbedExternal) { + mutateState(s => { + const post = s.posts[postId] + if (!post) return null + s.posts[postId] = setPostExternal(post, external) + s.isDirty = true + return s + }) + } + + function removeExternalEmbed(postId: string) { + mutateState(s => { + const post = s.posts[postId] + if (!post) return null + if (post.external === undefined) return null + s.posts[postId] = setPostExternal(post, undefined) + s.isDirty = true + return s + }) + } + + function setQuoteEmbed(postId: string, quote: types.PostEmbedQuote) { + mutateState(s => { + const post = s.posts[postId] + if (!post) return null + s.posts[postId] = {...post, quote} + s.isDirty = true + return s + }) + } + + function removeQuoteEmbed(postId: string) { + mutateState(s => { + const post = s.posts[postId] + if (!post) return null + if (post.quote === undefined) return null + s.posts[postId] = {...post, quote: undefined} + s.isDirty = true + return s + }) + } + /** * Public so the simulated upload worker can push progress in. Real callers - * should not invoke this directly; use queueImageUpload / retryImageUpload. + * should not invoke this directly; use addMedia / retryMediaUpload. * * Failed inputs are wrapped here with a `retry()` method bound to this * (postId, mediaId) so consumers reading the status from state can retry @@ -299,14 +346,34 @@ export function createThreadStore(options: { } /** - * The single chokepoint for replacing a post's media array. Recomputes the + * Single chokepoint for replacing a post's media array. Recomputes the * derived selectionsRemaining flags so they never drift from the array. */ function setPostMedia( post: types.ThreadPost, media: types.PostEmbedMedia[], ): types.ThreadPost { - return {...post, media, ...computePostMediaSelectionsRemaining(media)} + return { + ...post, + media, + ...computePostMediaSelectionsRemaining(media, post.external), + } + } + + /** + * Single chokepoint for replacing a post's external link card. Mirrors + * setPostMedia so the selectionsRemaining flags stay consistent (an + * external link blocks all media selections). + */ + function setPostExternal( + post: types.ThreadPost, + external: types.PostEmbedExternal | undefined, + ): types.ThreadPost { + return { + ...post, + external, + ...computePostMediaSelectionsRemaining(post.media, external), + } } return { @@ -320,6 +387,10 @@ export function createThreadStore(options: { removeMedia, updateMediaAltText, retryMediaUpload, + setExternalEmbed, + removeExternalEmbed, + setQuoteEmbed, + removeQuoteEmbed, setUploadStatus, }, destroy() { diff --git a/src/components/ComposerV2/store/types.ts b/src/components/ComposerV2/store/types.ts index 904836a592..b7db7215e7 100644 --- a/src/components/ComposerV2/store/types.ts +++ b/src/components/ComposerV2/store/types.ts @@ -1,4 +1,4 @@ -import {type AppBskyFeedDefs, type BlobRef} from '@atproto/api' +import {type BlobRef} from '@atproto/api' import {type Gif} from '#/state/queries/tenor' @@ -129,13 +129,6 @@ export type AddMediaInput = altText?: string } -export type ThreadReplyTo = { - uri: string - cid: string - authorDid: string - view?: AppBskyFeedDefs.PostView -} - export type ThreadState = { /** * Posts keyed by id. Insertion order is the thread order; rely on object diff --git a/src/components/ComposerV2/store/uploads.ts b/src/components/ComposerV2/store/uploads.ts index d158d3b433..6c3ee8db23 100644 --- a/src/components/ComposerV2/store/uploads.ts +++ b/src/components/ComposerV2/store/uploads.ts @@ -6,9 +6,13 @@ * any pending work; this is what the store stores per-media so it can cancel * an in-flight upload when the user removes or replaces the media. * - * TODO: replace the simulated progression with real AtpAgent.uploadBlob calls - * (com.atproto.repo.uploadBlob) for images. The public surface here should - * not need to change; the simulation lives entirely inside startImageUpload. + * TODO: replace the simulated progression with real implementations: + * - images: AtpAgent.uploadBlob (com.atproto.repo.uploadBlob) + * - video: the existing video pipeline (compress, create upload job, poll + * until ready, resolve to a BlobRef) + * The public surface here (startImageUpload / startVideoUpload returning an + * UploadTask) should not need to change; the simulation lives entirely + * inside runSimulatedUpload. */ import {type AtpAgent, type BlobRef} from '@atproto/api' diff --git a/src/components/ComposerV2/store/utils/buildThreadPost.ts b/src/components/ComposerV2/store/utils/buildThreadPost.ts index 9a60352694..3d15588b2f 100644 --- a/src/components/ComposerV2/store/utils/buildThreadPost.ts +++ b/src/components/ComposerV2/store/utils/buildThreadPost.ts @@ -9,6 +9,6 @@ export function buildThreadPost(): ThreadPost { media: [], external: undefined, quote: undefined, - ...computePostMediaSelectionsRemaining([]), + ...computePostMediaSelectionsRemaining([], undefined), } } diff --git a/src/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining.ts b/src/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining.ts index ef8f1a9d00..01b77c4787 100644 --- a/src/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining.ts +++ b/src/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining.ts @@ -1,17 +1,30 @@ import {MAX_IMAGES_PER_POST} from '#/components/ComposerV2/store/const' -import {type PostEmbedMedia} from '#/components/ComposerV2/store/types' +import { + type PostEmbedExternal, + type PostEmbedMedia, +} from '#/components/ComposerV2/store/types' /** - * Mirrors filterMediaInputs' rules. With no media, all kinds are open at - * their per-kind cap. With existing images, only images are open up to a - * total of MAX_IMAGES_PER_POST. With an existing video or gif, nothing more - * can be added. + * Mirrors filterMediaInputs' rules. With no media (and no external link), + * all kinds are open at their per-kind cap. With existing images, only + * images are open up to a total of MAX_IMAGES_PER_POST. With an existing + * video, gif, or external link card, nothing more can be added. */ -export function computePostMediaSelectionsRemaining(media: PostEmbedMedia[]): { +export function computePostMediaSelectionsRemaining( + media: PostEmbedMedia[], + external: PostEmbedExternal | undefined, +): { imageSelectionsRemaining: number videoSelectionsRemaining: number gifSelectionsRemaining: number } { + if (external !== undefined) { + return { + imageSelectionsRemaining: 0, + videoSelectionsRemaining: 0, + gifSelectionsRemaining: 0, + } + } if (media.length === 0) { return { imageSelectionsRemaining: MAX_IMAGES_PER_POST, diff --git a/src/components/ComposerV2/store/utils/filterMediaInputs.ts b/src/components/ComposerV2/store/utils/filterMediaInputs.ts index 2f53682b39..a05314d8cc 100644 --- a/src/components/ComposerV2/store/utils/filterMediaInputs.ts +++ b/src/components/ComposerV2/store/utils/filterMediaInputs.ts @@ -5,9 +5,8 @@ import { } from '#/components/ComposerV2/store/types' /** - * Main validation logic for addMedia inputs. Enforces the bsky media rules: - * a post can have up to 4 images, OR 1 video, OR 1 gif. Mixing is not - * allowed. + * Filters and caps a list of addMedia inputs based on what's already on the + * post: * * - If the post already has a video or gif, addMedia is a no-op. * - If the post already has images, only image inputs are accepted, up to a @@ -16,9 +15,10 @@ import { * for the call: items of any other kind are dropped, and the remainder is * capped at the per-kind limit (4 images, 1 video, 1 gif). * - * NOTE: this is likely temporary - the rules will probably move into a - * richer validation layer that the UI can also consult to gate the picker - * and surface helpful messages. + * NOTE: this is likely temporary - the first-input-dictates-kind rule is a + * placeholder until the UI gates the picker properly. The mutual-exclusion + * with external link cards is permanent and is enforced by the caller + * (addMedia) before invoking this function. */ export function filterMediaInputs( existing: PostEmbedMedia[],