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(ids).toEqual([])
|
||||||
expect(store.getState()).toBe(before)
|
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', () => {
|
describe('selectionsRemaining flags on the post', () => {
|
||||||
@@ -302,6 +312,27 @@ describe('selectionsRemaining flags on the post', () => {
|
|||||||
expect(post.videoSelectionsRemaining).toBe(1)
|
expect(post.videoSelectionsRemaining).toBe(1)
|
||||||
expect(post.gifSelectionsRemaining).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', () => {
|
describe('removeMedia', () => {
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ export function createThreadStore(options: {
|
|||||||
if (!(postId in state.posts)) return undefined
|
if (!(postId in state.posts)) return undefined
|
||||||
if (inputs.length === 0) return []
|
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)
|
const accepted = filterMediaInputs(state.posts[postId].media, inputs)
|
||||||
if (accepted.length === 0) return []
|
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
|
* 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
|
* Failed inputs are wrapped here with a `retry()` method bound to this
|
||||||
* (postId, mediaId) so consumers reading the status from state can retry
|
* (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.
|
* derived selectionsRemaining flags so they never drift from the array.
|
||||||
*/
|
*/
|
||||||
function setPostMedia(
|
function setPostMedia(
|
||||||
post: types.ThreadPost,
|
post: types.ThreadPost,
|
||||||
media: types.PostEmbedMedia[],
|
media: types.PostEmbedMedia[],
|
||||||
): types.ThreadPost {
|
): 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 {
|
return {
|
||||||
@@ -320,6 +387,10 @@ export function createThreadStore(options: {
|
|||||||
removeMedia,
|
removeMedia,
|
||||||
updateMediaAltText,
|
updateMediaAltText,
|
||||||
retryMediaUpload,
|
retryMediaUpload,
|
||||||
|
setExternalEmbed,
|
||||||
|
removeExternalEmbed,
|
||||||
|
setQuoteEmbed,
|
||||||
|
removeQuoteEmbed,
|
||||||
setUploadStatus,
|
setUploadStatus,
|
||||||
},
|
},
|
||||||
destroy() {
|
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'
|
import {type Gif} from '#/state/queries/tenor'
|
||||||
|
|
||||||
@@ -129,13 +129,6 @@ export type AddMediaInput =
|
|||||||
altText?: string
|
altText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ThreadReplyTo = {
|
|
||||||
uri: string
|
|
||||||
cid: string
|
|
||||||
authorDid: string
|
|
||||||
view?: AppBskyFeedDefs.PostView
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ThreadState = {
|
export type ThreadState = {
|
||||||
/**
|
/**
|
||||||
* Posts keyed by id. Insertion order is the thread order; rely on object
|
* 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
|
* 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.
|
* an in-flight upload when the user removes or replaces the media.
|
||||||
*
|
*
|
||||||
* TODO: replace the simulated progression with real AtpAgent.uploadBlob calls
|
* TODO: replace the simulated progression with real implementations:
|
||||||
* (com.atproto.repo.uploadBlob) for images. The public surface here should
|
* - images: AtpAgent.uploadBlob (com.atproto.repo.uploadBlob)
|
||||||
* not need to change; the simulation lives entirely inside startImageUpload.
|
* - 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'
|
import {type AtpAgent, type BlobRef} from '@atproto/api'
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ export function buildThreadPost(): ThreadPost {
|
|||||||
media: [],
|
media: [],
|
||||||
external: undefined,
|
external: undefined,
|
||||||
quote: undefined,
|
quote: undefined,
|
||||||
...computePostMediaSelectionsRemaining([]),
|
...computePostMediaSelectionsRemaining([], undefined),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
import {MAX_IMAGES_PER_POST} from '#/components/ComposerV2/store/const'
|
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
|
* Mirrors filterMediaInputs' rules. With no media (and no external link),
|
||||||
* their per-kind cap. With existing images, only images are open up to a
|
* all kinds are open at their per-kind cap. With existing images, only
|
||||||
* total of MAX_IMAGES_PER_POST. With an existing video or gif, nothing more
|
* images are open up to a total of MAX_IMAGES_PER_POST. With an existing
|
||||||
* can be added.
|
* 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
|
imageSelectionsRemaining: number
|
||||||
videoSelectionsRemaining: number
|
videoSelectionsRemaining: number
|
||||||
gifSelectionsRemaining: number
|
gifSelectionsRemaining: number
|
||||||
} {
|
} {
|
||||||
|
if (external !== undefined) {
|
||||||
|
return {
|
||||||
|
imageSelectionsRemaining: 0,
|
||||||
|
videoSelectionsRemaining: 0,
|
||||||
|
gifSelectionsRemaining: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
if (media.length === 0) {
|
if (media.length === 0) {
|
||||||
return {
|
return {
|
||||||
imageSelectionsRemaining: MAX_IMAGES_PER_POST,
|
imageSelectionsRemaining: MAX_IMAGES_PER_POST,
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import {
|
|||||||
} from '#/components/ComposerV2/store/types'
|
} from '#/components/ComposerV2/store/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main validation logic for addMedia inputs. Enforces the bsky media rules:
|
* Filters and caps a list of addMedia inputs based on what's already on the
|
||||||
* a post can have up to 4 images, OR 1 video, OR 1 gif. Mixing is not
|
* post:
|
||||||
* allowed.
|
|
||||||
*
|
*
|
||||||
* - If the post already has a video or gif, addMedia is a no-op.
|
* - 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
|
* - 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
|
* 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).
|
* capped at the per-kind limit (4 images, 1 video, 1 gif).
|
||||||
*
|
*
|
||||||
* NOTE: this is likely temporary - the rules will probably move into a
|
* NOTE: this is likely temporary - the first-input-dictates-kind rule is a
|
||||||
* richer validation layer that the UI can also consult to gate the picker
|
* placeholder until the UI gates the picker properly. The mutual-exclusion
|
||||||
* and surface helpful messages.
|
* with external link cards is permanent and is enforced by the caller
|
||||||
|
* (addMedia) before invoking this function.
|
||||||
*/
|
*/
|
||||||
export function filterMediaInputs(
|
export function filterMediaInputs(
|
||||||
existing: PostEmbedMedia[],
|
existing: PostEmbedMedia[],
|
||||||
|
|||||||
Reference in New Issue
Block a user