checkpoint unified media

This commit is contained in:
Eric Bailey
2026-05-01 16:22:13 +01:00
parent e3ba1c7c4c
commit a6ad6e8da4
10 changed files with 781 additions and 350 deletions
@@ -1,251 +0,0 @@
import {beforeEach, describe, expect, jest, test} from '@jest/globals'
import {type AtpAgent} from '@atproto/api'
import {createThreadStore} from '#/components/ComposerV2/store'
import {type PostEmbedMedia} from '#/components/ComposerV2/store/types'
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]
}
function getMedia(
store: ReturnType<typeof createThreadStore>,
postId: string,
): PostEmbedMedia[] {
return store.getState().posts[postId].media
}
const sampleImage = () => ({
uri: 'file:///tmp/a.jpg',
width: 100,
height: 100,
})
beforeEach(() => {
jest.useFakeTimers()
})
describe('queueImageUpload', () => {
test('appends an image with pending upload status and returns its id', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const imageId = store.actions.queueImageUpload(root, sampleImage())
expect(imageId).toBe('id-2')
const media = getMedia(store, root)
expect(media).toHaveLength(1)
expect(media[0].kind).toBe('image')
expect(media[0].id).toBe('id-2')
expect(media[0].kind === 'image' && media[0].upload).toEqual({
state: 'pending',
})
})
test('marks state dirty', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
expect(store.getState().isDirty).toBe(false)
store.actions.queueImageUpload(rootId(store), sampleImage())
expect(store.getState().isDirty).toBe(true)
})
test('attaches the post id to the new media item', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const imageId = store.actions.queueImageUpload(root, sampleImage())!
const media = getMedia(store, root).find(m => m.id === imageId)!
expect(media.postId).toBe(root)
})
test('returns undefined and is a no-op when post id is unknown', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const before = store.getState()
const result = store.actions.queueImageUpload('does-not-exist', sampleImage())
expect(result).toBeUndefined()
expect(store.getState()).toBe(before)
})
test('drives upload from pending -> uploading -> uploaded', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const imageId = store.actions.queueImageUpload(root, sampleImage())!
const get = () => {
const m = getMedia(store, root).find(x => x.id === imageId)!
if (m.kind !== 'image') throw new Error('expected image')
return m.upload
}
expect(get().state).toBe('pending')
jest.advanceTimersByTime(100)
expect(get().state).toBe('uploading')
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('multiple queued images coexist on the same post', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.queueImageUpload(root, sampleImage())
store.actions.queueImageUpload(root, sampleImage())
expect(getMedia(store, root)).toHaveLength(2)
})
})
describe('removeImage', () => {
test('removes the image from media and leaves other media intact', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const a = store.actions.queueImageUpload(root, sampleImage())!
const b = store.actions.queueImageUpload(root, sampleImage())!
store.actions.removeImage(root, a)
const media = getMedia(store, root)
expect(media.map(m => m.id)).toEqual([b])
})
test('cancels in-flight upload (no further status writes after removal)', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const imageId = store.actions.queueImageUpload(root, sampleImage())!
// Let one tick of progress happen, then remove.
jest.advanceTimersByTime(100)
store.actions.removeImage(root, imageId)
// Drain the rest of the simulated upload; no media should reappear.
expect(() => jest.runAllTimers()).not.toThrow()
expect(getMedia(store, root)).toHaveLength(0)
})
test('is a no-op when image id is unknown', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.queueImageUpload(root, sampleImage())
const before = store.getState()
store.actions.removeImage(root, 'does-not-exist')
expect(store.getState()).toBe(before)
})
})
describe('retryImageUpload', () => {
test('resets a failed upload back to pending and walks it through to uploaded', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const imageId = store.actions.queueImageUpload(root, sampleImage())!
// Simulate a failure by overriding status directly.
store.actions.setUploadStatus(root, imageId, {
state: 'failed',
error: 'boom',
})
const get = () => {
const m = getMedia(store, root).find(x => x.id === imageId)!
if (m.kind !== 'image') throw new Error('expected image')
return m.upload
}
expect(get().state).toBe('failed')
store.actions.retryImageUpload(root, imageId)
expect(get().state).toBe('pending')
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('failed status carries a bound retry() method that restarts the upload', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const imageId = store.actions.queueImageUpload(root, sampleImage())!
store.actions.setUploadStatus(root, imageId, {
state: 'failed',
error: 'network',
})
const get = () => {
const m = getMedia(store, root).find(x => x.id === imageId)!
if (m.kind !== 'image') throw new Error('expected image')
return m.upload
}
const failed = get()
if (failed.state !== 'failed') throw new Error('expected failed')
expect(typeof failed.retry).toBe('function')
failed.retry()
expect(get().state).toBe('pending')
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('cancels an existing in-flight task before starting a new one', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const imageId = store.actions.queueImageUpload(root, sampleImage())!
// Partially advance the original task.
jest.advanceTimersByTime(100)
store.actions.retryImageUpload(root, imageId)
const get = () => {
const m = getMedia(store, root).find(x => x.id === imageId)!
if (m.kind !== 'image') throw new Error('expected image')
return m.upload
}
expect(get().state).toBe('pending')
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('is a no-op when post or image id is unknown', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.queueImageUpload(root, sampleImage())
const before = store.getState()
store.actions.retryImageUpload(root, 'does-not-exist')
expect(store.getState()).toBe(before)
store.actions.retryImageUpload('nope', 'whatever')
expect(store.getState()).toBe(before)
})
})
describe('setImageAltText', () => {
test('updates only the matching image', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const a = store.actions.queueImageUpload(root, sampleImage())!
const b = store.actions.queueImageUpload(root, sampleImage())!
store.actions.setImageAltText(root, b, 'a description')
const media = getMedia(store, root)
expect(media.find(m => m.id === a)?.kind === 'image' && media.find(m => m.id === a)).toMatchObject({altText: ''})
expect(media.find(m => m.id === b)?.kind === 'image' && media.find(m => m.id === b)).toMatchObject({altText: 'a description'})
})
})
describe('removePost cancels media uploads', () => {
test('removing a post cancels any in-flight uploads on that post', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const a = rootId(store)
const b = store.actions.addPost('after', a)
store.actions.queueImageUpload(b, sampleImage())
jest.advanceTimersByTime(100)
store.actions.removePost(b)
// Draining timers should not crash and the removed post stays gone.
expect(() => jest.runAllTimers()).not.toThrow()
expect(Object.keys(store.getState().posts)).toEqual([a])
})
})
describe('destroy cancels uploads', () => {
test('destroy stops any in-flight uploads', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.queueImageUpload(root, sampleImage())
jest.advanceTimersByTime(100)
store.destroy()
expect(() => jest.runAllTimers()).not.toThrow()
})
})
@@ -0,0 +1,458 @@
import {type AtpAgent} from '@atproto/api'
import {beforeEach, describe, expect, jest, test} from '@jest/globals'
import {type Gif} from '#/state/queries/tenor'
import {createThreadStore} from '#/components/ComposerV2/store'
import {
type AddMediaInput,
type PostEmbedMedia,
} from '#/components/ComposerV2/store/types'
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]
}
function getMedia(
store: ReturnType<typeof createThreadStore>,
postId: string,
): PostEmbedMedia[] {
return store.getState().posts[postId].media
}
const imageInput: AddMediaInput = {
kind: 'image',
uri: 'file:///tmp/a.jpg',
width: 100,
height: 100,
}
const videoInput: AddMediaInput = {
kind: 'video',
uri: 'file:///tmp/a.mp4',
width: 1920,
height: 1080,
mimeType: 'video/mp4',
}
const gifInput: AddMediaInput = {
kind: 'gif',
// Tests don't read inside the gif, so a minimal cast is fine.
gif: {url: 'https://example.com/g.gif'} as Gif,
}
beforeEach(() => {
jest.useFakeTimers()
})
describe('addMedia', () => {
test('adds a single image with pending upload status and returns its id', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const ids = store.actions.addMedia(root, [imageInput])
expect(ids).toEqual(['id-2'])
const media = getMedia(store, root)
expect(media).toHaveLength(1)
expect(media[0].kind).toBe('image')
expect(media[0].id).toBe('id-2')
expect(media[0].postId).toBe(root)
if (media[0].kind !== 'image') throw new Error('expected image')
expect(media[0].upload).toEqual({state: 'pending'})
})
test('preserves input order in returned ids and on the post', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const ids = store.actions.addMedia(root, [
imageInput,
imageInput,
imageInput,
])
expect(ids).toHaveLength(3)
expect(getMedia(store, root).map(m => m.id)).toEqual(ids)
})
test('marks state dirty', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
expect(store.getState().isDirty).toBe(false)
store.actions.addMedia(rootId(store), [imageInput])
expect(store.getState().isDirty).toBe(true)
})
test('returns undefined and is a no-op when post id is unknown', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const before = store.getState()
const result = store.actions.addMedia('does-not-exist', [imageInput])
expect(result).toBeUndefined()
expect(store.getState()).toBe(before)
})
test('returns [] for an empty input list and is a no-op', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const before = store.getState()
const result = store.actions.addMedia(rootId(store), [])
expect(result).toEqual([])
expect(store.getState()).toBe(before)
})
test('drives an image upload from pending -> uploading -> uploaded', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const [imageId] = store.actions.addMedia(root, [imageInput])!
const get = () => {
const m = getMedia(store, root).find(x => x.id === imageId)!
if (m.kind !== 'image') throw new Error('expected image')
return m.upload
}
expect(get().state).toBe('pending')
jest.advanceTimersByTime(100)
expect(get().state).toBe('uploading')
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('drives a video upload through to uploaded', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const [videoId] = store.actions.addMedia(root, [videoInput])!
const get = () => {
const m = getMedia(store, root).find(x => x.id === videoId)!
if (m.kind !== 'video') throw new Error('expected video')
return m.upload
}
expect(get().state).toBe('pending')
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('does not start an upload task for a gif', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.addMedia(root, [gifInput])
jest.runAllTimers()
const media = getMedia(store, root)
expect(media[0].kind).toBe('gif')
// Gif media records have no `upload` field.
expect('upload' in media[0]).toBe(false)
})
})
describe('addMedia input validation (first item dictates kind, cap by count)', () => {
test('image-first: filters out non-images and caps at 4', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const ids = store.actions.addMedia(root, [
imageInput,
videoInput,
imageInput,
imageInput,
gifInput,
imageInput,
imageInput,
])
expect(ids).toHaveLength(4)
const media = getMedia(store, root)
expect(media).toHaveLength(4)
expect(media.every(m => m.kind === 'image')).toBe(true)
})
test('video-first: filters out non-videos and caps at 1', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const ids = store.actions.addMedia(root, [
videoInput,
imageInput,
videoInput,
])
expect(ids).toHaveLength(1)
expect(getMedia(store, root)[0].kind).toBe('video')
})
test('gif-first: filters out non-gifs and caps at 1', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const ids = store.actions.addMedia(root, [gifInput, gifInput, imageInput])
expect(ids).toHaveLength(1)
expect(getMedia(store, root)[0].kind).toBe('gif')
})
})
describe('addMedia respects existing media on the post', () => {
test('appends images up to a total of 4 when the post already has images', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.addMedia(root, [imageInput, imageInput])
const ids = store.actions.addMedia(root, [
imageInput,
imageInput,
imageInput,
])
// Two existing + capacity of 2 more.
expect(ids).toHaveLength(2)
expect(getMedia(store, root)).toHaveLength(4)
})
test('drops non-image inputs when the post already has images', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.addMedia(root, [imageInput])
const ids = store.actions.addMedia(root, [videoInput, gifInput])
expect(ids).toEqual([])
expect(getMedia(store, root)).toHaveLength(1)
})
test('is a no-op when the post already has 4 images', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.addMedia(root, [
imageInput,
imageInput,
imageInput,
imageInput,
])
const before = store.getState()
const ids = store.actions.addMedia(root, [imageInput])
expect(ids).toEqual([])
expect(store.getState()).toBe(before)
})
test('is a no-op when the post already has a video', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.addMedia(root, [videoInput])
const before = store.getState()
const ids = store.actions.addMedia(root, [imageInput, gifInput])
expect(ids).toEqual([])
expect(store.getState()).toBe(before)
})
test('is a no-op when the post already has a gif', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.addMedia(root, [gifInput])
const before = store.getState()
const ids = store.actions.addMedia(root, [imageInput, videoInput])
expect(ids).toEqual([])
expect(store.getState()).toBe(before)
})
})
describe('selectionsRemaining flags on the post', () => {
test('empty post starts with 4 / 1 / 1', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const post = store.getState().posts[rootId(store)]
expect(post.imageSelectionsRemaining).toBe(4)
expect(post.videoSelectionsRemaining).toBe(1)
expect(post.gifSelectionsRemaining).toBe(1)
})
test('decrements as images are added', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.addMedia(root, [imageInput, imageInput])
let post = store.getState().posts[root]
expect(post.imageSelectionsRemaining).toBe(2)
expect(post.videoSelectionsRemaining).toBe(0)
expect(post.gifSelectionsRemaining).toBe(0)
store.actions.addMedia(root, [imageInput, imageInput])
post = store.getState().posts[root]
expect(post.imageSelectionsRemaining).toBe(0)
})
test('a video locks all three counters to 0', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.addMedia(root, [videoInput])
const post = store.getState().posts[root]
expect(post.imageSelectionsRemaining).toBe(0)
expect(post.videoSelectionsRemaining).toBe(0)
expect(post.gifSelectionsRemaining).toBe(0)
})
test('a gif locks all three counters to 0', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.addMedia(root, [gifInput])
const post = store.getState().posts[root]
expect(post.imageSelectionsRemaining).toBe(0)
expect(post.videoSelectionsRemaining).toBe(0)
expect(post.gifSelectionsRemaining).toBe(0)
})
test('removing media restores capacity', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const [imgId] = store.actions.addMedia(root, [imageInput])!
expect(store.getState().posts[root].imageSelectionsRemaining).toBe(3)
store.actions.removeMedia(root, imgId)
const post = store.getState().posts[root]
expect(post.imageSelectionsRemaining).toBe(4)
expect(post.videoSelectionsRemaining).toBe(1)
expect(post.gifSelectionsRemaining).toBe(1)
})
})
describe('removeMedia', () => {
test('removes the matching media and leaves others intact', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const [a, b] = store.actions.addMedia(root, [imageInput, imageInput])!
store.actions.removeMedia(root, a)
expect(getMedia(store, root).map(m => m.id)).toEqual([b])
})
test('cancels in-flight upload (no further status writes after removal)', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const [imageId] = store.actions.addMedia(root, [imageInput])!
jest.advanceTimersByTime(100)
store.actions.removeMedia(root, imageId)
expect(() => jest.runAllTimers()).not.toThrow()
expect(getMedia(store, root)).toHaveLength(0)
})
test('is a no-op when media id is unknown', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.addMedia(root, [imageInput])
const before = store.getState()
store.actions.removeMedia(root, 'does-not-exist')
expect(store.getState()).toBe(before)
})
})
describe('retryMediaUpload', () => {
test('resets a failed image upload back to pending and walks it to uploaded', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const [imageId] = store.actions.addMedia(root, [imageInput])!
store.actions.setUploadStatus(root, imageId, {
state: 'failed',
error: 'boom',
})
const get = () => {
const m = getMedia(store, root).find(x => x.id === imageId)!
if (m.kind !== 'image') throw new Error('expected image')
return m.upload
}
expect(get().state).toBe('failed')
store.actions.retryMediaUpload(root, imageId)
expect(get().state).toBe('pending')
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('failed status carries a bound retry() method that restarts the upload', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const [imageId] = store.actions.addMedia(root, [imageInput])!
store.actions.setUploadStatus(root, imageId, {
state: 'failed',
error: 'network',
})
const get = () => {
const m = getMedia(store, root).find(x => x.id === imageId)!
if (m.kind !== 'image') throw new Error('expected image')
return m.upload
}
const failed = get()
if (failed.state !== 'failed') throw new Error('expected failed')
expect(typeof failed.retry).toBe('function')
failed.retry()
expect(get().state).toBe('pending')
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('is a no-op for a gif media id', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const [gifId] = store.actions.addMedia(root, [gifInput])!
const before = store.getState()
store.actions.retryMediaUpload(root, gifId)
expect(store.getState()).toBe(before)
})
test('is a no-op when post or media id is unknown', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.addMedia(root, [imageInput])
const before = store.getState()
store.actions.retryMediaUpload(root, 'does-not-exist')
expect(store.getState()).toBe(before)
store.actions.retryMediaUpload('nope', 'whatever')
expect(store.getState()).toBe(before)
})
})
describe('updateMediaAltText', () => {
test('updates only the matching media (image)', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const [a, b] = store.actions.addMedia(root, [imageInput, imageInput])!
store.actions.updateMediaAltText(root, b, 'a description')
const media = getMedia(store, root)
expect(media.find(m => m.id === a)?.altText).toBe('')
expect(media.find(m => m.id === b)?.altText).toBe('a description')
})
test('works on a gif as well', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const [gifId] = store.actions.addMedia(root, [gifInput])!
store.actions.updateMediaAltText(root, gifId, 'animated joy')
expect(getMedia(store, root)[0].altText).toBe('animated joy')
})
test('is a no-op when alt text is unchanged', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const [imageId] = store.actions.addMedia(root, [imageInput])!
const before = store.getState()
store.actions.updateMediaAltText(root, imageId, '')
expect(store.getState()).toBe(before)
})
})
describe('removePost cancels media uploads', () => {
test('removing a post cancels any in-flight uploads on that post', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const a = rootId(store)
const b = store.actions.addPost('after', a)
store.actions.addMedia(b, [imageInput])
jest.advanceTimersByTime(100)
store.actions.removePost(b)
expect(() => jest.runAllTimers()).not.toThrow()
expect(Object.keys(store.getState().posts)).toEqual([a])
})
})
describe('destroy cancels uploads', () => {
test('destroy stops any in-flight uploads', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.addMedia(root, [imageInput])
jest.advanceTimersByTime(100)
store.destroy()
expect(() => jest.runAllTimers()).not.toThrow()
})
})
+5
View File
@@ -0,0 +1,5 @@
/**
* Maximum number of images that can be attached to a single post.
* Mirrors the bsky lex limit on app.bsky.embed.images.
*/
export const MAX_IMAGES_PER_POST = 4
+131 -93
View File
@@ -1,8 +1,16 @@
import {type AtpAgent} from '@atproto/api'
import {nanoid} from 'nanoid/non-secure'
import * as types from '#/components/ComposerV2/store/types'
import {startImageUpload, type UploadTask} from '#/components/ComposerV2/store/uploads'
import type * as types from '#/components/ComposerV2/store/types'
import {
startImageUpload,
startVideoUpload,
type UploadTask,
} from '#/components/ComposerV2/store/uploads'
import {buildPostMediaItem} from '#/components/ComposerV2/store/utils/buildPostMediaItem'
import {buildThreadPost} from '#/components/ComposerV2/store/utils/buildThreadPost'
import {computePostMediaSelectionsRemaining} from '#/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining'
import {filterMediaInputs} from '#/components/ComposerV2/store/utils/filterMediaInputs'
type Listener = () => void
@@ -14,7 +22,7 @@ export function createThreadStore(options: {
const id = options.__createId ?? nanoid
const agent = options.agent
let state: types.ThreadState = {
posts: {[id()]: createEmptyThreadPost()},
posts: {[id()]: buildThreadPost()},
isDirty: false,
draftId: undefined,
}
@@ -35,9 +43,7 @@ export function createThreadStore(options: {
* shallow-clone the top-level object so getState() returns a new reference,
* which is what useSyncExternalStore needs to trigger a rerender.
*/
function mutateState(
fn: (s: types.ThreadState) => types.ThreadState | null,
) {
function mutateState(fn: (s: types.ThreadState) => types.ThreadState | null) {
if (destroyed) return
const next = fn(state)
if (next === null) return
@@ -88,11 +94,11 @@ export function createThreadStore(options: {
const next: Record<string, types.ThreadPost> = {}
for (const [k, v] of Object.entries(s.posts)) {
if (position === 'before' && k === postId) {
next[newId] = createEmptyThreadPost()
next[newId] = buildThreadPost()
}
next[k] = v
if (position === 'after' && k === postId) {
next[newId] = createEmptyThreadPost()
next[newId] = buildThreadPost()
}
}
s.posts = next
@@ -115,106 +121,138 @@ export function createThreadStore(options: {
})
}
function queueImageUpload(
/**
* Add one or more media items to a post and start any required uploads.
* Accepts a heterogeneous list (images, video, gif). Gifs don't kick off
* an upload task; images and videos do.
*
* Returns the new media ids in input order, or undefined if the postId
* doesn't exist (no items are added in that case).
*/
function addMedia(
postId: string,
input: {
uri: string
width: number
height: number
altText?: string
},
): string | undefined {
inputs: types.AddMediaInput[],
): string[] | undefined {
if (!(postId in state.posts)) return undefined
const imageId = id()
if (inputs.length === 0) return []
const accepted = filterMediaInputs(state.posts[postId].media, inputs)
if (accepted.length === 0) return []
const newIds = accepted.map(() => id())
const newItems: types.PostEmbedMedia[] = accepted.map((input, i) =>
buildPostMediaItem(input, {id: newIds[i], postId}),
)
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
const item: types.PostEmbedMedia = {
kind: 'image',
id: imageId,
postId,
uri: input.uri,
width: input.width,
height: input.height,
altText: input.altText ?? '',
upload: {state: 'pending'},
s.posts[postId] = setPostMedia(post, [...post.media, ...newItems])
s.isDirty = true
return s
})
for (let i = 0; i < accepted.length; i++) {
const input = accepted[i]
const mediaId = newIds[i]
if (input.kind === 'image') {
uploadTasks.set(
mediaId,
startImageUpload({
postId,
mediaId,
uri: input.uri,
agent,
setUploadStatus,
}),
)
} else if (input.kind === 'video') {
uploadTasks.set(
mediaId,
startVideoUpload({
postId,
mediaId,
uri: input.uri,
agent,
setUploadStatus,
}),
)
}
s.posts[postId] = {...post, media: [...post.media, item]}
s.isDirty = true
return s
})
uploadTasks.set(
imageId,
startImageUpload({
postId,
mediaId: imageId,
uri: input.uri,
agent,
setUploadStatus,
}),
)
return imageId
// gif: no upload task
}
return newIds
}
function removeImage(postId: string, imageId: string) {
cancelUploadTask(imageId)
function removeMedia(postId: string, mediaId: string) {
cancelUploadTask(mediaId)
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
const next = post.media.filter(m => m.id !== imageId)
const next = post.media.filter(m => m.id !== mediaId)
if (next.length === post.media.length) return null
s.posts[postId] = {...post, media: next}
s.posts[postId] = setPostMedia(post, next)
s.isDirty = true
return s
})
}
function retryImageUpload(postId: string, imageId: string) {
const post = state.posts[postId]
if (!post) return
const item = post.media.find(m => m.id === imageId)
if (!item || item.kind !== 'image') return
cancelUploadTask(imageId)
mutateState(s => {
const p = s.posts[postId]
if (!p) return null
const media = p.media.map(m =>
m.id === imageId ? {...m, upload: {state: 'pending' as const}} : m,
)
s.posts[postId] = {...p, media}
return s
})
uploadTasks.set(
imageId,
startImageUpload({
postId,
mediaId: imageId,
uri: item.uri,
agent,
setUploadStatus,
}),
)
}
function setImageAltText(postId: string, imageId: string, altText: string) {
function updateMediaAltText(
postId: string,
mediaId: string,
altText: string,
) {
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
let changed = false
const media = post.media.map(m => {
if (m.id !== imageId || m.kind !== 'image') return m
if (m.id !== mediaId) return m
if (m.altText === altText) return m
changed = true
return {...m, altText}
})
if (!changed) return null
s.posts[postId] = {...post, media}
s.posts[postId] = setPostMedia(post, media)
s.isDirty = true
return s
})
}
/**
* Restart a failed (or in-flight) upload for an image or video. No-ops on
* a gif (no upload lifecycle) or on unknown ids.
*/
function retryMediaUpload(postId: string, mediaId: string) {
const post = state.posts[postId]
if (!post) return
const item = post.media.find(m => m.id === mediaId)
if (!item) return
if (item.kind === 'gif') return
cancelUploadTask(mediaId)
mutateState(s => {
const p = s.posts[postId]
if (!p) return null
const media = p.media.map(m =>
m.id === mediaId ? {...m, upload: {state: 'pending' as const}} : m,
)
s.posts[postId] = setPostMedia(p, media)
return s
})
const start = item.kind === 'image' ? startImageUpload : startVideoUpload
uploadTasks.set(
mediaId,
start({
postId,
mediaId,
uri: item.uri,
agent,
setUploadStatus,
}),
)
}
/**
* Public so the simulated upload worker can push progress in. Real callers
* should not invoke this directly; use queueImageUpload / retryImageUpload.
@@ -230,7 +268,7 @@ export function createThreadStore(options: {
) {
const status: types.PostMediaUploadStatus =
statusInput.state === 'failed'
? {...statusInput, retry: () => retryImageUpload(postId, mediaId)}
? {...statusInput, retry: () => retryMediaUpload(postId, mediaId)}
: statusInput
mutateState(s => {
@@ -243,7 +281,7 @@ export function createThreadStore(options: {
if (found.kind === 'gif') return null
const media = post.media.slice()
media[idx] = {...found, upload: status}
s.posts[postId] = {...post, media}
s.posts[postId] = setPostMedia(post, media)
// Upload progress isn't a user edit, so don't mark dirty here.
return s
})
@@ -260,6 +298,17 @@ export function createThreadStore(options: {
}
}
/**
* 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 {
actions: {
setPostText,
@@ -267,10 +316,10 @@ export function createThreadStore(options: {
setPostLabels,
addPost,
removePost,
queueImageUpload,
removeImage,
retryImageUpload,
setImageAltText,
addMedia,
removeMedia,
updateMediaAltText,
retryMediaUpload,
setUploadStatus,
},
destroy() {
@@ -289,14 +338,3 @@ export function createThreadStore(options: {
},
}
}
export function createEmptyThreadPost(): types.ThreadPost {
return {
text: '',
langs: [],
labels: [],
media: [],
external: undefined,
quote: undefined,
}
}
+35
View File
@@ -92,8 +92,43 @@ export type ThreadPost = {
media: PostEmbedMedia[]
external: PostEmbedExternal | undefined
quote: PostEmbedQuote | undefined
/**
* Derived from `media`. How many more items of each kind addMedia would
* accept on this post given the current state. Kept in sync by the store
* whenever `media` is mutated; UI can read these directly to gate pickers.
*/
imageSelectionsRemaining: number
videoSelectionsRemaining: number
gifSelectionsRemaining: number
}
/**
* Input shape for addMedia. Each entry carries its own kind discriminator
* plus the kind-specific source fields. The store generates ids and
* postIds; callers don't deal with either.
*/
export type AddMediaInput =
| {
kind: 'image'
uri: string
width: number
height: number
altText?: string
}
| {
kind: 'video'
uri: string
width: number
height: number
mimeType: string
altText?: string
}
| {
kind: 'gif'
gif: Gif
altText?: string
}
export type ThreadReplyTo = {
uri: string
cid: string
+22 -6
View File
@@ -18,7 +18,7 @@ export type UploadTask = {
cancel(): void
}
type StartImageUploadOptions = {
type StartUploadOptions = {
postId: string
mediaId: string
uri: string
@@ -32,8 +32,24 @@ type StartImageUploadOptions = {
const IMAGE_PROGRESS_STEPS = [0.25, 0.5, 0.75]
const IMAGE_TICK_MS = 100
const VIDEO_PROGRESS_STEPS = [0.1, 0.3, 0.5, 0.7, 0.9]
const VIDEO_TICK_MS = 200
export function startImageUpload(opts: StartImageUploadOptions): UploadTask {
export function startImageUpload(opts: StartUploadOptions): UploadTask {
return runSimulatedUpload(opts, IMAGE_PROGRESS_STEPS, IMAGE_TICK_MS)
}
export function startVideoUpload(opts: StartUploadOptions): UploadTask {
// TODO: replace with real video pipeline (compress, create upload job,
// poll until ready, resolve to a BlobRef).
return runSimulatedUpload(opts, VIDEO_PROGRESS_STEPS, VIDEO_TICK_MS)
}
function runSimulatedUpload(
opts: StartUploadOptions,
progressSteps: number[],
tickMs: number,
): UploadTask {
let cancelled = false
let timeoutId: ReturnType<typeof setTimeout> | null = null
let stepIndex = 0
@@ -42,13 +58,13 @@ export function startImageUpload(opts: StartImageUploadOptions): UploadTask {
timeoutId = null
if (cancelled) return
if (stepIndex < IMAGE_PROGRESS_STEPS.length) {
if (stepIndex < progressSteps.length) {
opts.setUploadStatus(opts.postId, opts.mediaId, {
state: 'uploading',
progress: IMAGE_PROGRESS_STEPS[stepIndex],
progress: progressSteps[stepIndex],
})
stepIndex += 1
timeoutId = setTimeout(tick, IMAGE_TICK_MS)
timeoutId = setTimeout(tick, tickMs)
return
}
@@ -58,7 +74,7 @@ export function startImageUpload(opts: StartImageUploadOptions): UploadTask {
})
}
timeoutId = setTimeout(tick, IMAGE_TICK_MS)
timeoutId = setTimeout(tick, tickMs)
return {
cancel() {
@@ -0,0 +1,43 @@
import {
type AddMediaInput,
type PostEmbedMedia,
} from '#/components/ComposerV2/store/types'
export function buildPostMediaItem(
input: AddMediaInput,
ids: {id: string; postId: string},
): PostEmbedMedia {
if (input.kind === 'image') {
return {
kind: 'image',
id: ids.id,
postId: ids.postId,
uri: input.uri,
width: input.width,
height: input.height,
altText: input.altText ?? '',
upload: {state: 'pending'},
}
}
if (input.kind === 'video') {
return {
kind: 'video',
id: ids.id,
postId: ids.postId,
uri: input.uri,
width: input.width,
height: input.height,
mimeType: input.mimeType,
altText: input.altText ?? '',
captions: [],
upload: {state: 'pending'},
}
}
return {
kind: 'gif',
id: ids.id,
postId: ids.postId,
gif: input.gif,
altText: input.altText ?? '',
}
}
@@ -0,0 +1,14 @@
import {type ThreadPost} from '#/components/ComposerV2/store/types'
import {computePostMediaSelectionsRemaining} from '#/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining'
export function buildThreadPost(): ThreadPost {
return {
text: '',
langs: [],
labels: [],
media: [],
external: undefined,
quote: undefined,
...computePostMediaSelectionsRemaining([]),
}
}
@@ -0,0 +1,34 @@
import {MAX_IMAGES_PER_POST} from '#/components/ComposerV2/store/const'
import {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.
*/
export function computePostMediaSelectionsRemaining(media: PostEmbedMedia[]): {
imageSelectionsRemaining: number
videoSelectionsRemaining: number
gifSelectionsRemaining: number
} {
if (media.length === 0) {
return {
imageSelectionsRemaining: MAX_IMAGES_PER_POST,
videoSelectionsRemaining: 1,
gifSelectionsRemaining: 1,
}
}
if (media[0].kind === 'image') {
return {
imageSelectionsRemaining: Math.max(0, MAX_IMAGES_PER_POST - media.length),
videoSelectionsRemaining: 0,
gifSelectionsRemaining: 0,
}
}
return {
imageSelectionsRemaining: 0,
videoSelectionsRemaining: 0,
gifSelectionsRemaining: 0,
}
}
@@ -0,0 +1,39 @@
import {MAX_IMAGES_PER_POST} from '#/components/ComposerV2/store/const'
import {
type AddMediaInput,
type PostEmbedMedia,
} 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.
*
* - 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
* total of MAX_IMAGES_PER_POST (existing + new).
* - If the post has no media yet, the first input's kind dictates the kind
* 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.
*/
export function filterMediaInputs(
existing: PostEmbedMedia[],
inputs: AddMediaInput[],
): AddMediaInput[] {
if (existing.length > 0) {
// Existing media is locked into a single kind; only same-kind images
// can be appended, otherwise nothing more is accepted.
if (existing[0].kind !== 'image') return []
const remaining = MAX_IMAGES_PER_POST - existing.length
if (remaining <= 0) return []
return inputs.filter(i => i.kind === 'image').slice(0, remaining)
}
const kind = inputs[0].kind
const sameKind = inputs.filter(i => i.kind === kind)
const cap = kind === 'image' ? MAX_IMAGES_PER_POST : 1
return sameKind.slice(0, cap)
}