diff --git a/src/components/ComposerV2/store/__tests__/images.test.ts b/src/components/ComposerV2/store/__tests__/images.test.ts deleted file mode 100644 index 13f67f1ed3..0000000000 --- a/src/components/ComposerV2/store/__tests__/images.test.ts +++ /dev/null @@ -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) { - return Object.keys(store.getState().posts)[0] -} - -function getMedia( - store: ReturnType, - 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() - }) -}) diff --git a/src/components/ComposerV2/store/__tests__/media.test.ts b/src/components/ComposerV2/store/__tests__/media.test.ts new file mode 100644 index 0000000000..598b1755df --- /dev/null +++ b/src/components/ComposerV2/store/__tests__/media.test.ts @@ -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) { + return Object.keys(store.getState().posts)[0] +} + +function getMedia( + store: ReturnType, + 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() + }) +}) diff --git a/src/components/ComposerV2/store/const.ts b/src/components/ComposerV2/store/const.ts new file mode 100644 index 0000000000..909a7341c1 --- /dev/null +++ b/src/components/ComposerV2/store/const.ts @@ -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 diff --git a/src/components/ComposerV2/store/index.ts b/src/components/ComposerV2/store/index.ts index d998a4dbe8..1eb178e111 100644 --- a/src/components/ComposerV2/store/index.ts +++ b/src/components/ComposerV2/store/index.ts @@ -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 = {} 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, - } -} diff --git a/src/components/ComposerV2/store/types.ts b/src/components/ComposerV2/store/types.ts index a0cb334344..904836a592 100644 --- a/src/components/ComposerV2/store/types.ts +++ b/src/components/ComposerV2/store/types.ts @@ -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 diff --git a/src/components/ComposerV2/store/uploads.ts b/src/components/ComposerV2/store/uploads.ts index 7d089d6929..d158d3b433 100644 --- a/src/components/ComposerV2/store/uploads.ts +++ b/src/components/ComposerV2/store/uploads.ts @@ -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 | 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() { diff --git a/src/components/ComposerV2/store/utils/buildPostMediaItem.ts b/src/components/ComposerV2/store/utils/buildPostMediaItem.ts new file mode 100644 index 0000000000..62c489bd94 --- /dev/null +++ b/src/components/ComposerV2/store/utils/buildPostMediaItem.ts @@ -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 ?? '', + } +} diff --git a/src/components/ComposerV2/store/utils/buildThreadPost.ts b/src/components/ComposerV2/store/utils/buildThreadPost.ts new file mode 100644 index 0000000000..9a60352694 --- /dev/null +++ b/src/components/ComposerV2/store/utils/buildThreadPost.ts @@ -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([]), + } +} diff --git a/src/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining.ts b/src/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining.ts new file mode 100644 index 0000000000..ef8f1a9d00 --- /dev/null +++ b/src/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining.ts @@ -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, + } +} diff --git a/src/components/ComposerV2/store/utils/filterMediaInputs.ts b/src/components/ComposerV2/store/utils/filterMediaInputs.ts new file mode 100644 index 0000000000..2f53682b39 --- /dev/null +++ b/src/components/ComposerV2/store/utils/filterMediaInputs.ts @@ -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) +}