checkopoint full picture, time to wite it up

This commit is contained in:
Eric Bailey
2026-05-01 16:45:02 +01:00
parent a6ad6e8da4
commit a1eb8f9765
8 changed files with 242 additions and 27 deletions
@@ -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<typeof createThreadStore>) {
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)
})
})
@@ -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', () => {
+74 -3
View File
@@ -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() {
+1 -8
View File
@@ -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
+7 -3
View File
@@ -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'
@@ -9,6 +9,6 @@ export function buildThreadPost(): ThreadPost {
media: [],
external: undefined,
quote: undefined,
...computePostMediaSelectionsRemaining([]),
...computePostMediaSelectionsRemaining([], undefined),
}
}
@@ -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,
@@ -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[],