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 {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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user