checkopoint full picture, time to wite it up
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user