checkpoint unified media
This commit is contained in:
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
import {type AtpAgent} from '@atproto/api'
|
import {type AtpAgent} from '@atproto/api'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
import * as types from '#/components/ComposerV2/store/types'
|
import type * as types from '#/components/ComposerV2/store/types'
|
||||||
import {startImageUpload, type UploadTask} from '#/components/ComposerV2/store/uploads'
|
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
|
type Listener = () => void
|
||||||
|
|
||||||
@@ -14,7 +22,7 @@ export function createThreadStore(options: {
|
|||||||
const id = options.__createId ?? nanoid
|
const id = options.__createId ?? nanoid
|
||||||
const agent = options.agent
|
const agent = options.agent
|
||||||
let state: types.ThreadState = {
|
let state: types.ThreadState = {
|
||||||
posts: {[id()]: createEmptyThreadPost()},
|
posts: {[id()]: buildThreadPost()},
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
draftId: undefined,
|
draftId: undefined,
|
||||||
}
|
}
|
||||||
@@ -35,9 +43,7 @@ export function createThreadStore(options: {
|
|||||||
* shallow-clone the top-level object so getState() returns a new reference,
|
* shallow-clone the top-level object so getState() returns a new reference,
|
||||||
* which is what useSyncExternalStore needs to trigger a rerender.
|
* which is what useSyncExternalStore needs to trigger a rerender.
|
||||||
*/
|
*/
|
||||||
function mutateState(
|
function mutateState(fn: (s: types.ThreadState) => types.ThreadState | null) {
|
||||||
fn: (s: types.ThreadState) => types.ThreadState | null,
|
|
||||||
) {
|
|
||||||
if (destroyed) return
|
if (destroyed) return
|
||||||
const next = fn(state)
|
const next = fn(state)
|
||||||
if (next === null) return
|
if (next === null) return
|
||||||
@@ -88,11 +94,11 @@ export function createThreadStore(options: {
|
|||||||
const next: Record<string, types.ThreadPost> = {}
|
const next: Record<string, types.ThreadPost> = {}
|
||||||
for (const [k, v] of Object.entries(s.posts)) {
|
for (const [k, v] of Object.entries(s.posts)) {
|
||||||
if (position === 'before' && k === postId) {
|
if (position === 'before' && k === postId) {
|
||||||
next[newId] = createEmptyThreadPost()
|
next[newId] = buildThreadPost()
|
||||||
}
|
}
|
||||||
next[k] = v
|
next[k] = v
|
||||||
if (position === 'after' && k === postId) {
|
if (position === 'after' && k === postId) {
|
||||||
next[newId] = createEmptyThreadPost()
|
next[newId] = buildThreadPost()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.posts = next
|
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,
|
postId: string,
|
||||||
input: {
|
inputs: types.AddMediaInput[],
|
||||||
uri: string
|
): string[] | undefined {
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
altText?: string
|
|
||||||
},
|
|
||||||
): string | undefined {
|
|
||||||
if (!(postId in state.posts)) return 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 => {
|
mutateState(s => {
|
||||||
const post = s.posts[postId]
|
const post = s.posts[postId]
|
||||||
if (!post) return null
|
if (!post) return null
|
||||||
const item: types.PostEmbedMedia = {
|
s.posts[postId] = setPostMedia(post, [...post.media, ...newItems])
|
||||||
kind: 'image',
|
s.isDirty = true
|
||||||
id: imageId,
|
return s
|
||||||
postId,
|
})
|
||||||
uri: input.uri,
|
|
||||||
width: input.width,
|
for (let i = 0; i < accepted.length; i++) {
|
||||||
height: input.height,
|
const input = accepted[i]
|
||||||
altText: input.altText ?? '',
|
const mediaId = newIds[i]
|
||||||
upload: {state: 'pending'},
|
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]}
|
// gif: no upload task
|
||||||
s.isDirty = true
|
}
|
||||||
return s
|
|
||||||
})
|
return newIds
|
||||||
uploadTasks.set(
|
|
||||||
imageId,
|
|
||||||
startImageUpload({
|
|
||||||
postId,
|
|
||||||
mediaId: imageId,
|
|
||||||
uri: input.uri,
|
|
||||||
agent,
|
|
||||||
setUploadStatus,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return imageId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeImage(postId: string, imageId: string) {
|
function removeMedia(postId: string, mediaId: string) {
|
||||||
cancelUploadTask(imageId)
|
cancelUploadTask(mediaId)
|
||||||
mutateState(s => {
|
mutateState(s => {
|
||||||
const post = s.posts[postId]
|
const post = s.posts[postId]
|
||||||
if (!post) return null
|
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
|
if (next.length === post.media.length) return null
|
||||||
s.posts[postId] = {...post, media: next}
|
s.posts[postId] = setPostMedia(post, next)
|
||||||
s.isDirty = true
|
s.isDirty = true
|
||||||
return s
|
return s
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function retryImageUpload(postId: string, imageId: string) {
|
function updateMediaAltText(
|
||||||
const post = state.posts[postId]
|
postId: string,
|
||||||
if (!post) return
|
mediaId: string,
|
||||||
const item = post.media.find(m => m.id === imageId)
|
altText: string,
|
||||||
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) {
|
|
||||||
mutateState(s => {
|
mutateState(s => {
|
||||||
const post = s.posts[postId]
|
const post = s.posts[postId]
|
||||||
if (!post) return null
|
if (!post) return null
|
||||||
let changed = false
|
let changed = false
|
||||||
const media = post.media.map(m => {
|
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
|
if (m.altText === altText) return m
|
||||||
changed = true
|
changed = true
|
||||||
return {...m, altText}
|
return {...m, altText}
|
||||||
})
|
})
|
||||||
if (!changed) return null
|
if (!changed) return null
|
||||||
s.posts[postId] = {...post, media}
|
s.posts[postId] = setPostMedia(post, media)
|
||||||
s.isDirty = true
|
s.isDirty = true
|
||||||
return s
|
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
|
* 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 queueImageUpload / retryImageUpload.
|
||||||
@@ -230,7 +268,7 @@ export function createThreadStore(options: {
|
|||||||
) {
|
) {
|
||||||
const status: types.PostMediaUploadStatus =
|
const status: types.PostMediaUploadStatus =
|
||||||
statusInput.state === 'failed'
|
statusInput.state === 'failed'
|
||||||
? {...statusInput, retry: () => retryImageUpload(postId, mediaId)}
|
? {...statusInput, retry: () => retryMediaUpload(postId, mediaId)}
|
||||||
: statusInput
|
: statusInput
|
||||||
|
|
||||||
mutateState(s => {
|
mutateState(s => {
|
||||||
@@ -243,7 +281,7 @@ export function createThreadStore(options: {
|
|||||||
if (found.kind === 'gif') return null
|
if (found.kind === 'gif') return null
|
||||||
const media = post.media.slice()
|
const media = post.media.slice()
|
||||||
media[idx] = {...found, upload: status}
|
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.
|
// Upload progress isn't a user edit, so don't mark dirty here.
|
||||||
return s
|
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 {
|
return {
|
||||||
actions: {
|
actions: {
|
||||||
setPostText,
|
setPostText,
|
||||||
@@ -267,10 +316,10 @@ export function createThreadStore(options: {
|
|||||||
setPostLabels,
|
setPostLabels,
|
||||||
addPost,
|
addPost,
|
||||||
removePost,
|
removePost,
|
||||||
queueImageUpload,
|
addMedia,
|
||||||
removeImage,
|
removeMedia,
|
||||||
retryImageUpload,
|
updateMediaAltText,
|
||||||
setImageAltText,
|
retryMediaUpload,
|
||||||
setUploadStatus,
|
setUploadStatus,
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
@@ -289,14 +338,3 @@ export function createThreadStore(options: {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEmptyThreadPost(): types.ThreadPost {
|
|
||||||
return {
|
|
||||||
text: '',
|
|
||||||
langs: [],
|
|
||||||
labels: [],
|
|
||||||
media: [],
|
|
||||||
external: undefined,
|
|
||||||
quote: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -92,8 +92,43 @@ export type ThreadPost = {
|
|||||||
media: PostEmbedMedia[]
|
media: PostEmbedMedia[]
|
||||||
external: PostEmbedExternal | undefined
|
external: PostEmbedExternal | undefined
|
||||||
quote: PostEmbedQuote | 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 = {
|
export type ThreadReplyTo = {
|
||||||
uri: string
|
uri: string
|
||||||
cid: string
|
cid: string
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export type UploadTask = {
|
|||||||
cancel(): void
|
cancel(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
type StartImageUploadOptions = {
|
type StartUploadOptions = {
|
||||||
postId: string
|
postId: string
|
||||||
mediaId: string
|
mediaId: string
|
||||||
uri: string
|
uri: string
|
||||||
@@ -32,8 +32,24 @@ type StartImageUploadOptions = {
|
|||||||
|
|
||||||
const IMAGE_PROGRESS_STEPS = [0.25, 0.5, 0.75]
|
const IMAGE_PROGRESS_STEPS = [0.25, 0.5, 0.75]
|
||||||
const IMAGE_TICK_MS = 100
|
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 cancelled = false
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
let stepIndex = 0
|
let stepIndex = 0
|
||||||
@@ -42,13 +58,13 @@ export function startImageUpload(opts: StartImageUploadOptions): UploadTask {
|
|||||||
timeoutId = null
|
timeoutId = null
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
|
||||||
if (stepIndex < IMAGE_PROGRESS_STEPS.length) {
|
if (stepIndex < progressSteps.length) {
|
||||||
opts.setUploadStatus(opts.postId, opts.mediaId, {
|
opts.setUploadStatus(opts.postId, opts.mediaId, {
|
||||||
state: 'uploading',
|
state: 'uploading',
|
||||||
progress: IMAGE_PROGRESS_STEPS[stepIndex],
|
progress: progressSteps[stepIndex],
|
||||||
})
|
})
|
||||||
stepIndex += 1
|
stepIndex += 1
|
||||||
timeoutId = setTimeout(tick, IMAGE_TICK_MS)
|
timeoutId = setTimeout(tick, tickMs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +74,7 @@ export function startImageUpload(opts: StartImageUploadOptions): UploadTask {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
timeoutId = setTimeout(tick, IMAGE_TICK_MS)
|
timeoutId = setTimeout(tick, tickMs)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cancel() {
|
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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user