From e3ba1c7c4cc2bafcbe5232e597a8543014a46f88 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 1 May 2026 15:37:11 +0100 Subject: [PATCH] checkpoint before media unification --- .../ComposerV2/lib/__tests__/reducers.test.ts | 304 +++++++++++++++++ .../ComposerV2/lib/__tests__/store.test.ts | 111 ++++++ src/components/ComposerV2/lib/reducers.ts | 320 ++++++++++++++++++ src/components/ComposerV2/lib/store.ts | 242 +++++++++++++ src/components/ComposerV2/lib/types.ts | 105 ++++++ src/components/ComposerV2/lib/uploads.ts | 93 +++++ .../ComposerV2/store/__tests__/images.test.ts | 251 ++++++++++++++ .../ComposerV2/store/__tests__/notify.test.ts | 69 ++++ .../ComposerV2/store/__tests__/posts.test.ts | 111 ++++++ src/components/ComposerV2/store/index.ts | 302 +++++++++++++++++ src/components/ComposerV2/store/types.ts | 115 +++++++ src/components/ComposerV2/store/uploads.ts | 86 +++++ 12 files changed, 2109 insertions(+) create mode 100644 src/components/ComposerV2/lib/__tests__/reducers.test.ts create mode 100644 src/components/ComposerV2/lib/__tests__/store.test.ts create mode 100644 src/components/ComposerV2/lib/reducers.ts create mode 100644 src/components/ComposerV2/lib/store.ts create mode 100644 src/components/ComposerV2/lib/types.ts create mode 100644 src/components/ComposerV2/lib/uploads.ts create mode 100644 src/components/ComposerV2/store/__tests__/images.test.ts create mode 100644 src/components/ComposerV2/store/__tests__/notify.test.ts create mode 100644 src/components/ComposerV2/store/__tests__/posts.test.ts create mode 100644 src/components/ComposerV2/store/index.ts create mode 100644 src/components/ComposerV2/store/types.ts create mode 100644 src/components/ComposerV2/store/uploads.ts diff --git a/src/components/ComposerV2/lib/__tests__/reducers.test.ts b/src/components/ComposerV2/lib/__tests__/reducers.test.ts new file mode 100644 index 0000000000..967ad7bc59 --- /dev/null +++ b/src/components/ComposerV2/lib/__tests__/reducers.test.ts @@ -0,0 +1,304 @@ +import {describe, expect, test} from '@jest/globals' + +import * as reducers from '#/components/ComposerV2/lib/reducers' +import { + type ComposerState, + type GifItem, + type VideoItem, +} from '#/components/ComposerV2/lib/types' + +function makeState(): ComposerState { + return reducers.createInitialState({rootPostId: 'p1'}) +} + +function makeVideo(id = 'v1'): VideoItem { + return { + id, + uri: 'file:///video.mp4', + width: 100, + height: 100, + altText: '', + mimeType: 'video/mp4', + localRefPath: `video:video/mp4:${id}`, + captions: [], + upload: {state: 'pending'}, + } +} + +function makeGif(id = 'g1'): GifItem { + return { + id, + altText: '', + // The Gif type from tenor is large; for reducer tests the store doesn't + // inspect any of those fields, so we cast a minimal shape. + gif: {url: 'https://example.com/gif'} as GifItem['gif'], + } +} + +describe('createInitialState', () => { + test('starts with one empty post', () => { + const s = reducers.createInitialState({rootPostId: 'root'}) + expect(s.posts).toHaveLength(1) + expect(s.posts[0].id).toBe('root') + expect(s.posts[0].text).toBe('') + expect(s.isDirty).toBe(false) + }) + + test('captures replyTo and draftId when provided', () => { + const s = reducers.createInitialState({ + rootPostId: 'root', + replyTo: {uri: 'at://x', cid: 'c', authorDid: 'did:plc:a'}, + draftId: 'draft-1', + }) + expect(s.replyTo?.uri).toBe('at://x') + expect(s.draftId).toBe('draft-1') + }) +}) + +describe('updateText', () => { + test('sets text on the matching post and marks dirty', () => { + const s = reducers.updateText(makeState(), {postId: 'p1', text: 'hello'}) + expect(s.posts[0].text).toBe('hello') + expect(s.isDirty).toBe(true) + }) + + test('returns identical reference when text unchanged', () => { + const s1 = makeState() + const s2 = reducers.updateText(s1, {postId: 'p1', text: ''}) + expect(s2).toBe(s1) + }) + + test('leaves state unchanged when post id is unknown', () => { + const s1 = makeState() + const s2 = reducers.updateText(s1, {postId: 'nope', text: 'hi'}) + expect(s2).toBe(s1) + }) +}) + +describe('appendPost / insertPostAfter / removePost', () => { + test('appendPost adds a post at the end', () => { + const s = reducers.appendPost(makeState(), {id: 'p2'}) + expect(s.posts.map(p => p.id)).toEqual(['p1', 'p2']) + expect(s.isDirty).toBe(true) + }) + + test('insertPostAfter places a post immediately after the target', () => { + let s = reducers.appendPost(makeState(), {id: 'p2'}) + s = reducers.appendPost(s, {id: 'p3'}) + s = reducers.insertPostAfter(s, {afterId: 'p1', id: 'p1b'}) + expect(s.posts.map(p => p.id)).toEqual(['p1', 'p1b', 'p2', 'p3']) + }) + + test('insertPostAfter is a no-op for an unknown afterId', () => { + const s1 = makeState() + const s2 = reducers.insertPostAfter(s1, {afterId: 'nope', id: 'x'}) + expect(s2).toBe(s1) + }) + + test('removePost removes the matching post', () => { + let s = reducers.appendPost(makeState(), {id: 'p2'}) + s = reducers.appendPost(s, {id: 'p3'}) + s = reducers.removePost(s, {postId: 'p2'}) + expect(s.posts.map(p => p.id)).toEqual(['p1', 'p3']) + }) + + test('removePost is a no-op for an unknown post id', () => { + const s1 = reducers.appendPost(makeState(), {id: 'p2'}) + const s2 = reducers.removePost(s1, {postId: 'nope'}) + expect(s2).toBe(s1) + }) + + test('removePost refuses to remove the last remaining post', () => { + const s1 = makeState() + const s2 = reducers.removePost(s1, {postId: 'p1'}) + expect(s2).toBe(s1) + expect(s2.posts).toHaveLength(1) + }) +}) + +describe('image media', () => { + test('addImages appends images with pending upload status', () => { + const s = reducers.addImages(makeState(), { + postId: 'p1', + images: [ + { + id: 'i1', + uri: 'file:///a.jpg', + width: 1, + height: 1, + localRefPath: 'image:i1', + }, + ], + }) + expect(s.posts[0].media?.kind).toBe('images') + if (s.posts[0].media?.kind !== 'images') return + expect(s.posts[0].media.items).toHaveLength(1) + expect(s.posts[0].media.items[0].upload.state).toBe('pending') + }) + + test('addImages a second time appends rather than replacing', () => { + let s = reducers.addImages(makeState(), { + postId: 'p1', + images: [ + {id: 'i1', uri: 'a', width: 1, height: 1, localRefPath: 'image:i1'}, + ], + }) + s = reducers.addImages(s, { + postId: 'p1', + images: [ + {id: 'i2', uri: 'b', width: 1, height: 1, localRefPath: 'image:i2'}, + ], + }) + if (s.posts[0].media?.kind !== 'images') throw new Error('expected images') + expect(s.posts[0].media.items.map(i => i.id)).toEqual(['i1', 'i2']) + }) + + test('removeImage removes the matching image and clears media when last one is gone', () => { + let s = reducers.addImages(makeState(), { + postId: 'p1', + images: [ + {id: 'i1', uri: 'a', width: 1, height: 1, localRefPath: 'image:i1'}, + ], + }) + s = reducers.removeImage(s, {postId: 'p1', imageId: 'i1'}) + expect(s.posts[0].media).toBeUndefined() + }) + + test('updateImageAltText updates only the matching image', () => { + let s = reducers.addImages(makeState(), { + postId: 'p1', + images: [ + {id: 'i1', uri: 'a', width: 1, height: 1, localRefPath: 'image:i1'}, + {id: 'i2', uri: 'b', width: 1, height: 1, localRefPath: 'image:i2'}, + ], + }) + s = reducers.updateImageAltText(s, { + postId: 'p1', + imageId: 'i2', + altText: 'hello', + }) + if (s.posts[0].media?.kind !== 'images') throw new Error('expected images') + expect(s.posts[0].media.items[0].altText).toBe('') + expect(s.posts[0].media.items[1].altText).toBe('hello') + }) +}) + +describe('video and gif media', () => { + test('setVideo replaces any existing media with the video', () => { + let s = reducers.addImages(makeState(), { + postId: 'p1', + images: [ + {id: 'i1', uri: 'a', width: 1, height: 1, localRefPath: 'image:i1'}, + ], + }) + s = reducers.setVideo(s, {postId: 'p1', video: makeVideo()}) + expect(s.posts[0].media?.kind).toBe('video') + }) + + test('removeVideo only removes when current media is a video', () => { + let s = reducers.setGif(makeState(), {postId: 'p1', gif: makeGif()}) + s = reducers.removeVideo(s, {postId: 'p1'}) + expect(s.posts[0].media?.kind).toBe('gif') + }) + + test('setGif replaces any existing media with a gif', () => { + let s = reducers.setVideo(makeState(), {postId: 'p1', video: makeVideo()}) + s = reducers.setGif(s, {postId: 'p1', gif: makeGif()}) + expect(s.posts[0].media?.kind).toBe('gif') + }) +}) + +describe('external link and quote', () => { + test('setExternal stores a link card and removeExternal clears it', () => { + let s = reducers.setExternal(makeState(), { + postId: 'p1', + external: {uri: 'https://example.com'}, + }) + expect(s.posts[0].external?.uri).toBe('https://example.com') + s = reducers.removeExternal(s, {postId: 'p1'}) + expect(s.posts[0].external).toBeUndefined() + }) + + test('setQuote stores a quote and removeQuote clears it', () => { + let s = reducers.setQuote(makeState(), { + postId: 'p1', + quote: {uri: 'at://x', cid: 'c'}, + }) + expect(s.posts[0].quote?.uri).toBe('at://x') + s = reducers.removeQuote(s, {postId: 'p1'}) + expect(s.posts[0].quote).toBeUndefined() + }) +}) + +describe('updateLabels', () => { + test('replaces the labels array on the matching post', () => { + const s = reducers.updateLabels(makeState(), { + postId: 'p1', + labels: ['sexual', 'graphic-media'], + }) + expect(s.posts[0].labels).toEqual(['sexual', 'graphic-media']) + }) +}) + +describe('setUploadStatus', () => { + test('updates the upload field on the matching image', () => { + let s = reducers.addImages(makeState(), { + postId: 'p1', + images: [ + {id: 'i1', uri: 'a', width: 1, height: 1, localRefPath: 'image:i1'}, + ], + }) + s = reducers.setUploadStatus(s, { + mediaId: 'i1', + status: {state: 'uploading', progress: 0.5}, + }) + if (s.posts[0].media?.kind !== 'images') throw new Error('expected images') + expect(s.posts[0].media.items[0].upload).toEqual({ + state: 'uploading', + progress: 0.5, + }) + }) + + test('updates the upload field on a video', () => { + let s = reducers.setVideo(makeState(), {postId: 'p1', video: makeVideo()}) + s = reducers.setUploadStatus(s, { + mediaId: 'v1', + status: {state: 'failed', error: 'boom'}, + }) + if (s.posts[0].media?.kind !== 'video') throw new Error('expected video') + expect(s.posts[0].media.item.upload).toEqual({ + state: 'failed', + error: 'boom', + }) + }) + + test('returns identical state when the media id is unknown', () => { + const s1 = reducers.addImages(makeState(), { + postId: 'p1', + images: [ + {id: 'i1', uri: 'a', width: 1, height: 1, localRefPath: 'image:i1'}, + ], + }) + const s2 = reducers.setUploadStatus(s1, { + mediaId: 'gone', + status: {state: 'uploading', progress: 0.5}, + }) + expect(s2).toBe(s1) + }) + + test('does not mark the state dirty (background upload progress is not a user edit)', () => { + let s = reducers.addImages(makeState(), { + postId: 'p1', + images: [ + {id: 'i1', uri: 'a', width: 1, height: 1, localRefPath: 'image:i1'}, + ], + }) + // Reset dirty so we're isolating setUploadStatus' behavior. + s = {...s, isDirty: false} + const s2 = reducers.setUploadStatus(s, { + mediaId: 'i1', + status: {state: 'uploading', progress: 0.25}, + }) + expect(s2.isDirty).toBe(false) + }) +}) diff --git a/src/components/ComposerV2/lib/__tests__/store.test.ts b/src/components/ComposerV2/lib/__tests__/store.test.ts new file mode 100644 index 0000000000..46cf0147e5 --- /dev/null +++ b/src/components/ComposerV2/lib/__tests__/store.test.ts @@ -0,0 +1,111 @@ +import {beforeEach, describe, expect, jest, test} from '@jest/globals' +import {type AtpAgent} from '@atproto/api' + +import {createComposerStore} from '#/components/ComposerV2/lib/store' + +// Deterministic ids: 'id-1', 'id-2', ... so tests can assert on them. +function makeIdGenerator() { + let i = 0 + return () => `id-${++i}` +} + +// We never call the agent in these tests; just satisfy the type. +const agent = {} as AtpAgent + +describe('createComposerStore - basics', () => { + test('seeds with one empty post', () => { + const store = createComposerStore({agent, idGenerator: makeIdGenerator()}) + const state = store.getState() + expect(state.posts).toHaveLength(1) + expect(state.posts[0].id).toBe('id-1') + }) + + test('subscribers are notified on state change', () => { + const store = createComposerStore({agent, idGenerator: makeIdGenerator()}) + const fn = jest.fn() + const unsubscribe = store.subscribe(fn) + store.actions.updateText('id-1', 'hi') + expect(fn).toHaveBeenCalledTimes(1) + unsubscribe() + store.actions.updateText('id-1', 'hi again') + expect(fn).toHaveBeenCalledTimes(1) + }) + + test('subscribers are not notified when state is unchanged', () => { + const store = createComposerStore({agent, idGenerator: makeIdGenerator()}) + const fn = jest.fn() + store.subscribe(fn) + // Same text as initial empty string -> no-op + store.actions.updateText('id-1', '') + expect(fn).not.toHaveBeenCalled() + }) + + test('appendPost returns the new post id and appends it', () => { + const store = createComposerStore({agent, idGenerator: makeIdGenerator()}) + const newId = store.actions.appendPost() + expect(newId).toBe('id-2') + expect(store.getState().posts.map(p => p.id)).toEqual(['id-1', 'id-2']) + }) + + test('removePost works and refuses to remove the last post', () => { + const store = createComposerStore({agent, idGenerator: makeIdGenerator()}) + store.actions.appendPost() + store.actions.removePost('id-2') + expect(store.getState().posts.map(p => p.id)).toEqual(['id-1']) + // Last post is sticky + store.actions.removePost('id-1') + expect(store.getState().posts.map(p => p.id)).toEqual(['id-1']) + }) + + test('destroy stops applying actions and clears subscribers', () => { + const store = createComposerStore({agent, idGenerator: makeIdGenerator()}) + const fn = jest.fn() + store.subscribe(fn) + store.destroy() + store.actions.updateText('id-1', 'after destroy') + expect(fn).not.toHaveBeenCalled() + expect(store.getState().posts[0].text).toBe('') + }) +}) + +describe('createComposerStore - simulated image uploads', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + test('addImages drives upload state from pending -> uploading -> uploaded', () => { + const store = createComposerStore({agent, idGenerator: makeIdGenerator()}) + // Each addImages call uses 2 ids (one for the image id, one for its localRefPath), + // so the image id is 'id-2' here. + const ids = store.actions.addImages('id-1', [ + {uri: 'file:///a.jpg', width: 10, height: 10}, + ]) + expect(ids).toEqual(['id-2']) + + const get = () => { + const post = store.getState().posts[0] + if (post.media?.kind !== 'images') throw new Error('expected images') + return post.media.items[0].upload + } + + expect(get().state).toBe('pending') + jest.advanceTimersByTime(100) + expect(get().state).toBe('uploading') + // Run all remaining timers to completion + jest.runAllTimers() + expect(get().state).toBe('uploaded') + }) + + test('removing an image while uploading does not throw and leaves state stable', () => { + const store = createComposerStore({agent, idGenerator: makeIdGenerator()}) + const [imageId] = store.actions.addImages('id-1', [ + {uri: 'file:///a.jpg', width: 10, height: 10}, + ]) + jest.advanceTimersByTime(100) + store.actions.removeImage('id-1', imageId) + // Drain the rest of the simulated upload - setUploadStatus should silently + // no-op since the media is gone. + expect(() => jest.runAllTimers()).not.toThrow() + expect(store.getState().posts[0].media).toBeUndefined() + }) +}) diff --git a/src/components/ComposerV2/lib/reducers.ts b/src/components/ComposerV2/lib/reducers.ts new file mode 100644 index 0000000000..f52bf513d8 --- /dev/null +++ b/src/components/ComposerV2/lib/reducers.ts @@ -0,0 +1,320 @@ +/** + * Pure reducer functions for the ComposerV2 store. + * + * Each function takes the current state plus arguments and returns the next + * state. They never throw on missing IDs; they return state unchanged so the + * store layer doesn't have to coordinate "did anything happen" with async + * sources like upload workers that may race a removePost. + * + * IDs are passed in (not generated here) so reducers stay deterministic and + * tests don't need to mock id generation. + */ +import { + type ComposerState, + type ExternalEmbed, + type GifItem, + type ImageItem, + type NewImageInput, + type PostDraft, + type Quote, + type UploadStatus, + type VideoItem, +} from './types' + +export function createEmptyPost(id: string): PostDraft { + return { + id, + text: '', + langs: [], + labels: [], + media: undefined, + external: undefined, + quote: undefined, + } +} + +export function createInitialState(args: { + rootPostId: string + replyTo?: ComposerState['replyTo'] + draftId?: string +}): ComposerState { + const root = createEmptyPost(args.rootPostId) + return { + posts: [root], + replyTo: args.replyTo, + draftId: args.draftId, + isDirty: false, + } +} + +function mapPost( + state: ComposerState, + postId: string, + fn: (post: PostDraft) => PostDraft, +): ComposerState { + let changed = false + const posts = state.posts.map(post => { + if (post.id !== postId) return post + const next = fn(post) + if (next !== post) changed = true + return next + }) + if (!changed) return state + return markDirty({...state, posts}) +} + +function markDirty(state: ComposerState): ComposerState { + if (state.isDirty) return state + return {...state, isDirty: true} +} + +export function updateText( + state: ComposerState, + args: {postId: string; text: string}, +): ComposerState { + return mapPost(state, args.postId, post => { + if (post.text === args.text) return post + return {...post, text: args.text} + }) +} + +export function updateLangs( + state: ComposerState, + args: {postId: string; langs: string[]}, +): ComposerState { + return mapPost(state, args.postId, post => ({...post, langs: args.langs})) +} + +export function updateLabels( + state: ComposerState, + args: {postId: string; labels: string[]}, +): ComposerState { + return mapPost(state, args.postId, post => ({...post, labels: args.labels})) +} + +export function appendPost( + state: ComposerState, + args: {id: string}, +): ComposerState { + const newPost = createEmptyPost(args.id) + return markDirty({...state, posts: [...state.posts, newPost]}) +} + +export function insertPostAfter( + state: ComposerState, + args: {afterId: string; id: string}, +): ComposerState { + const idx = state.posts.findIndex(p => p.id === args.afterId) + if (idx === -1) return state + const newPost = createEmptyPost(args.id) + const posts = [ + ...state.posts.slice(0, idx + 1), + newPost, + ...state.posts.slice(idx + 1), + ] + return markDirty({...state, posts}) +} + +export function removePost( + state: ComposerState, + args: {postId: string}, +): ComposerState { + // Never allow removing the last post; the composer always has at least one. + if (state.posts.length <= 1) return state + const posts = state.posts.filter(p => p.id !== args.postId) + if (posts.length === state.posts.length) return state + return markDirty({...state, posts}) +} + +export function addImages( + state: ComposerState, + args: {postId: string; images: Array}, +): ComposerState { + return mapPost(state, args.postId, post => { + // Adding images replaces any existing video or gif since media is exclusive. + const existing = + post.media?.kind === 'images' ? post.media.items : ([] as ImageItem[]) + const next: ImageItem[] = [ + ...existing, + ...args.images.map(img => ({ + id: img.id, + uri: img.uri, + width: img.width, + height: img.height, + altText: img.altText ?? '', + localRefPath: img.localRefPath, + upload: {state: 'pending'} as UploadStatus, + })), + ] + return {...post, media: {kind: 'images', items: next}} + }) +} + +export function removeImage( + state: ComposerState, + args: {postId: string; imageId: string}, +): ComposerState { + return mapPost(state, args.postId, post => { + if (post.media?.kind !== 'images') return post + const items = post.media.items.filter(i => i.id !== args.imageId) + if (items.length === post.media.items.length) return post + if (items.length === 0) return {...post, media: undefined} + return {...post, media: {kind: 'images', items}} + }) +} + +export function updateImageAltText( + state: ComposerState, + args: {postId: string; imageId: string; altText: string}, +): ComposerState { + return mapPost(state, args.postId, post => { + if (post.media?.kind !== 'images') return post + const items = post.media.items.map(i => + i.id === args.imageId ? {...i, altText: args.altText} : i, + ) + return {...post, media: {kind: 'images', items}} + }) +} + +export function setVideo( + state: ComposerState, + args: {postId: string; video: VideoItem}, +): ComposerState { + return mapPost(state, args.postId, post => ({ + ...post, + media: {kind: 'video', item: args.video}, + })) +} + +export function removeVideo( + state: ComposerState, + args: {postId: string}, +): ComposerState { + return mapPost(state, args.postId, post => { + if (post.media?.kind !== 'video') return post + return {...post, media: undefined} + }) +} + +export function updateVideoAltText( + state: ComposerState, + args: {postId: string; altText: string}, +): ComposerState { + return mapPost(state, args.postId, post => { + if (post.media?.kind !== 'video') return post + return { + ...post, + media: {kind: 'video', item: {...post.media.item, altText: args.altText}}, + } + }) +} + +export function setGif( + state: ComposerState, + args: {postId: string; gif: GifItem}, +): ComposerState { + return mapPost(state, args.postId, post => ({ + ...post, + media: {kind: 'gif', item: args.gif}, + })) +} + +export function removeGif( + state: ComposerState, + args: {postId: string}, +): ComposerState { + return mapPost(state, args.postId, post => { + if (post.media?.kind !== 'gif') return post + return {...post, media: undefined} + }) +} + +export function updateGifAltText( + state: ComposerState, + args: {postId: string; altText: string}, +): ComposerState { + return mapPost(state, args.postId, post => { + if (post.media?.kind !== 'gif') return post + return { + ...post, + media: {kind: 'gif', item: {...post.media.item, altText: args.altText}}, + } + }) +} + +export function setExternal( + state: ComposerState, + args: {postId: string; external: ExternalEmbed}, +): ComposerState { + return mapPost(state, args.postId, post => ({...post, external: args.external})) +} + +export function removeExternal( + state: ComposerState, + args: {postId: string}, +): ComposerState { + return mapPost(state, args.postId, post => { + if (!post.external) return post + return {...post, external: undefined} + }) +} + +export function setQuote( + state: ComposerState, + args: {postId: string; quote: Quote}, +): ComposerState { + return mapPost(state, args.postId, post => ({...post, quote: args.quote})) +} + +export function removeQuote( + state: ComposerState, + args: {postId: string}, +): ComposerState { + return mapPost(state, args.postId, post => { + if (!post.quote) return post + return {...post, quote: undefined} + }) +} + +/** + * Update the upload status of a media item. Searches every post since the + * upload worker only knows the media id, not which post it lives on (and posts + * could have been reordered or removed in the meantime). + * + * Returns state unchanged if the media item no longer exists - this is the + * expected case when an upload completes after the user removed the media. + */ +export function setUploadStatus( + state: ComposerState, + args: {mediaId: string; status: UploadStatus}, +): ComposerState { + let changed = false + const posts = state.posts.map(post => { + if (!post.media) return post + if (post.media.kind === 'images') { + let itemChanged = false + const items = post.media.items.map(item => { + if (item.id !== args.mediaId) return item + itemChanged = true + return {...item, upload: args.status} + }) + if (!itemChanged) return post + changed = true + return {...post, media: {kind: 'images' as const, items}} + } + if (post.media.kind === 'video' && post.media.item.id === args.mediaId) { + changed = true + return { + ...post, + media: { + kind: 'video' as const, + item: {...post.media.item, upload: args.status}, + }, + } + } + return post + }) + if (!changed) return state + // Upload progress shouldn't itself mark dirty - it's not a user edit. + return {...state, posts} +} diff --git a/src/components/ComposerV2/lib/store.ts b/src/components/ComposerV2/lib/store.ts new file mode 100644 index 0000000000..6b300b400a --- /dev/null +++ b/src/components/ComposerV2/lib/store.ts @@ -0,0 +1,242 @@ +/** + * The ComposerV2 store: a small subscribable container around ComposerState. + * + * Provider-owned (one instance per composer session) so tests can construct a + * fresh store without any React tree. Pure reducers from ./reducers do all the + * state transitions; the store factory wraps them with id generation, listener + * notification, and side-effects like kicking off background uploads. + * + * The `agent` is held in a private closure for the upload worker. State held + * by the store is plain data so that snapshot/restore (for OS-resume) and + * thin-adapter export to the saved-draft format are trivial. + */ +import {type AtpAgent} from '@atproto/api' +import {nanoid} from 'nanoid/non-secure' + +import * as reducers from './reducers' +import {startImageUpload, startVideoUpload} from './uploads' +import { + type ComposerState, + type ExternalEmbed, + type GifItem, + type NewImageInput, + type NewVideoInput, + type Quote, + type ReplyTo, + type UploadStatus, +} from './types' + +type Listener = () => void + +export type ComposerActions = { + updateText(postId: string, text: string): void + updateLangs(postId: string, langs: string[]): void + updateLabels(postId: string, labels: string[]): void + + appendPost(): string + insertPostAfter(afterId: string): string + removePost(postId: string): void + setActivePost(postId: string): void + + addImages(postId: string, images: NewImageInput[]): string[] + removeImage(postId: string, imageId: string): void + updateImageAltText(postId: string, imageId: string, altText: string): void + + setVideo(postId: string, video: NewVideoInput): string + removeVideo(postId: string): void + updateVideoAltText(postId: string, altText: string): void + + setGif(postId: string, gif: Omit): string + removeGif(postId: string): void + updateGifAltText(postId: string, altText: string): void + + setExternal(postId: string, external: ExternalEmbed): void + removeExternal(postId: string): void + + setQuote(postId: string, quote: Quote): void + removeQuote(postId: string): void + + /** Called by the upload worker when an image or video upload progresses. */ + setUploadStatus(mediaId: string, status: UploadStatus): void +} + +export type ComposerStore = { + getState(): ComposerState + subscribe(listener: Listener): () => void + actions: ComposerActions + destroy(): void +} + +export type CreateComposerStoreOptions = { + agent: AtpAgent + replyTo?: ReplyTo + draftId?: string + /** Override id generation; useful for deterministic tests. */ + idGenerator?: () => string +} + +export function createComposerStore( + options: CreateComposerStoreOptions, +): ComposerStore { + const newId = options.idGenerator ?? nanoid + // The agent is captured by the upload worker via getAgent(); held here so + // future actions (publish, draft save) can reach it without re-passing. + const agent = options.agent + + let state: ComposerState = reducers.createInitialState({ + rootPostId: newId(), + replyTo: options.replyTo, + draftId: options.draftId, + }) + const listeners = new Set() + let destroyed = false + + function getState() { + return state + } + + function subscribe(listener: Listener) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + } + + function set(next: ComposerState) { + if (next === state) return + state = next + for (const listener of listeners) listener() + } + + function apply( + reducer: (s: ComposerState, args: A) => ComposerState, + args: A, + ) { + if (destroyed) return + set(reducer(state, args)) + } + + const actions: ComposerActions = { + updateText(postId, text) { + apply(reducers.updateText, {postId, text}) + }, + updateLangs(postId, langs) { + apply(reducers.updateLangs, {postId, langs}) + }, + updateLabels(postId, labels) { + apply(reducers.updateLabels, {postId, labels}) + }, + + appendPost() { + const id = newId() + apply(reducers.appendPost, {id}) + return id + }, + insertPostAfter(afterId) { + const id = newId() + apply(reducers.insertPostAfter, {afterId, id}) + return id + }, + removePost(postId) { + apply(reducers.removePost, {postId}) + }, + setActivePost(postId) { + apply(reducers.setActivePost, {postId}) + }, + + addImages(postId, images) { + const withIds = images.map(img => ({ + ...img, + id: newId(), + localRefPath: `image:${newId()}`, + })) + apply(reducers.addImages, {postId, images: withIds}) + for (const img of withIds) { + startImageUpload({ + mediaId: img.id, + uri: img.uri, + agent, + setUploadStatus: actions.setUploadStatus, + isAlive: () => !destroyed, + }) + } + return withIds.map(i => i.id) + }, + removeImage(postId, imageId) { + apply(reducers.removeImage, {postId, imageId}) + }, + updateImageAltText(postId, imageId, altText) { + apply(reducers.updateImageAltText, {postId, imageId, altText}) + }, + + setVideo(postId, video) { + const id = newId() + const localRefPath = `video:${video.mimeType}:${newId()}` + apply(reducers.setVideo, { + postId, + video: { + id, + uri: video.uri, + width: video.width, + height: video.height, + mimeType: video.mimeType, + altText: video.altText ?? '', + localRefPath, + captions: [], + upload: {state: 'pending'}, + }, + }) + startVideoUpload({ + mediaId: id, + uri: video.uri, + agent, + setUploadStatus: actions.setUploadStatus, + isAlive: () => !destroyed, + }) + return id + }, + removeVideo(postId) { + apply(reducers.removeVideo, {postId}) + }, + updateVideoAltText(postId, altText) { + apply(reducers.updateVideoAltText, {postId, altText}) + }, + + setGif(postId, gif) { + const id = newId() + apply(reducers.setGif, {postId, gif: {id, ...gif}}) + return id + }, + removeGif(postId) { + apply(reducers.removeGif, {postId}) + }, + updateGifAltText(postId, altText) { + apply(reducers.updateGifAltText, {postId, altText}) + }, + + setExternal(postId, external) { + apply(reducers.setExternal, {postId, external}) + }, + removeExternal(postId) { + apply(reducers.removeExternal, {postId}) + }, + + setQuote(postId, quote) { + apply(reducers.setQuote, {postId, quote}) + }, + removeQuote(postId) { + apply(reducers.removeQuote, {postId}) + }, + + setUploadStatus(mediaId, status) { + apply(reducers.setUploadStatus, {mediaId, status}) + }, + } + + function destroy() { + destroyed = true + listeners.clear() + } + + return {getState, subscribe, actions, destroy} +} diff --git a/src/components/ComposerV2/lib/types.ts b/src/components/ComposerV2/lib/types.ts new file mode 100644 index 0000000000..2f5c04cf3d --- /dev/null +++ b/src/components/ComposerV2/lib/types.ts @@ -0,0 +1,105 @@ +/** + * Types for the ComposerV2 store. + * + * The store holds the in-progress state of a thread the user is composing: + * one or more posts, each with text, optional media (images, video, or a gif), + * an optional external link card, an optional quote, and labels. Which post + * has UI focus is purely a view-layer concern and lives outside the store. + * + * Media uploads are first-class state. A background worker writes upload + * progress directly into the relevant media item via setUploadStatus, so + * components subscribed to that slice rerender without coordination. + */ +import {type AppBskyFeedDefs, type BlobRef} from '@atproto/api' + +import {type Gif} from '#/state/queries/tenor' + +export type UploadStatus = + | {state: 'pending'} + | {state: 'uploading'; progress: number} + | {state: 'uploaded'; blob: BlobRef} + | {state: 'failed'; error: string} + +export type ImageItem = { + id: string + uri: string + width: number + height: number + altText: string + /** Stable path used to round-trip through saved drafts without re-copying bytes. */ + localRefPath: string + upload: UploadStatus +} + +export type VideoItem = { + id: string + uri: string + width: number + height: number + altText: string + mimeType: string + localRefPath: string + captions: Array<{lang: string; content: string}> + upload: UploadStatus +} + +export type GifItem = { + id: string + gif: Gif + altText: string +} + +export type PostMedia = + | {kind: 'images'; items: ImageItem[]} + | {kind: 'video'; item: VideoItem} + | {kind: 'gif'; item: GifItem} + +export type ExternalEmbed = { + uri: string +} + +export type Quote = { + uri: string + cid: string +} + +export type PostDraft = { + id: string + text: string + langs: string[] + labels: string[] + media: PostMedia | undefined + external: ExternalEmbed | undefined + quote: Quote | undefined +} + +export type ReplyTo = { + uri: string + cid: string + authorDid: string + view?: AppBskyFeedDefs.PostView +} + +export type ComposerState = { + posts: PostDraft[] + replyTo: ReplyTo | undefined + /** ID of the saved draft this composer was opened from, if any. */ + draftId: string | undefined + /** True when local state has diverged from the loaded draft (or initial open state). */ + isDirty: boolean +} + +export type NewImageInput = { + uri: string + width: number + height: number + altText?: string +} + +export type NewVideoInput = { + uri: string + width: number + height: number + mimeType: string + altText?: string +} diff --git a/src/components/ComposerV2/lib/uploads.ts b/src/components/ComposerV2/lib/uploads.ts new file mode 100644 index 0000000000..5906630fe4 --- /dev/null +++ b/src/components/ComposerV2/lib/uploads.ts @@ -0,0 +1,93 @@ +/** + * Background upload worker. + * + * Currently simulated with timers so the rest of the store can be wired up and + * tested. Each public function kicks off a series of setUploadStatus calls + * that drive a media item from pending -> uploading (with progress ticks) -> + * uploaded (or failed). + * + * TODO: replace the simulated progression with real AtpAgent.uploadBlob calls + * for images (via com.atproto.repo.uploadBlob) and the video upload pipeline + * for videos (job creation + polling). The public surface here should not need + * to change; the simulation lives entirely inside startImageUpload / + * startVideoUpload. + */ +import {type AtpAgent, type BlobRef} from '@atproto/api' + +import {type UploadStatus} from './types' + +type UploadHandle = { + mediaId: string + uri: string + agent: AtpAgent + setUploadStatus: (mediaId: string, status: UploadStatus) => void + /** Returns false if the store has been destroyed; aborts in-flight progression. */ + isAlive: () => boolean +} + +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(handle: UploadHandle) { + // TODO: replace simulation with agent.uploadBlob({...}) and update progress + // from the underlying request. For now, drip progress then resolve. + runSimulatedUpload(handle, IMAGE_PROGRESS_STEPS, IMAGE_TICK_MS) +} + +export function startVideoUpload(handle: UploadHandle) { + // TODO: replace simulation with the real video pipeline (compress, create + // upload job, poll job status, resolve to a BlobRef). Same status contract. + runSimulatedUpload(handle, VIDEO_PROGRESS_STEPS, VIDEO_TICK_MS) +} + +function runSimulatedUpload( + handle: UploadHandle, + progressSteps: number[], + tickMs: number, +) { + let cancelled = false + let stepIndex = 0 + + function tick() { + if (cancelled || !handle.isAlive()) return + + if (stepIndex === 0) { + handle.setUploadStatus(handle.mediaId, { + state: 'uploading', + progress: progressSteps[0], + }) + } else if (stepIndex < progressSteps.length) { + handle.setUploadStatus(handle.mediaId, { + state: 'uploading', + progress: progressSteps[stepIndex], + }) + } else { + handle.setUploadStatus(handle.mediaId, { + state: 'uploaded', + blob: makePlaceholderBlobRef(), + }) + return + } + + stepIndex += 1 + setTimeout(tick, tickMs) + } + + setTimeout(tick, tickMs) +} + +/** + * Placeholder until the real upload returns a BlobRef from the server. + * Shape matches @atproto/api's BlobRef; the inner ref is a synthetic CID-like + * string. Real code path will overwrite this entirely. + */ +function makePlaceholderBlobRef(): BlobRef { + return { + $type: 'blob', + ref: {$link: 'simulated-upload-placeholder'}, + mimeType: 'application/octet-stream', + size: 0, + } as unknown as BlobRef +} diff --git a/src/components/ComposerV2/store/__tests__/images.test.ts b/src/components/ComposerV2/store/__tests__/images.test.ts new file mode 100644 index 0000000000..13f67f1ed3 --- /dev/null +++ b/src/components/ComposerV2/store/__tests__/images.test.ts @@ -0,0 +1,251 @@ +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__/notify.test.ts b/src/components/ComposerV2/store/__tests__/notify.test.ts new file mode 100644 index 0000000000..84ae2360ab --- /dev/null +++ b/src/components/ComposerV2/store/__tests__/notify.test.ts @@ -0,0 +1,69 @@ +import {describe, expect, jest, test} from '@jest/globals' +import {type AtpAgent} from '@atproto/api' + +import {createThreadStore} from '#/components/ComposerV2/store' + +function makeIdGenerator() { + let i = 0 + return () => `id-${++i}` +} + +const agent = {} as AtpAgent + +function rootId(store: ReturnType) { + return Object.keys(store.getState().posts)[0] +} + +describe('subscribe / getState', () => { + test('listener fires on a real change and getState returns a new reference', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const root = rootId(store) + const before = store.getState() + const fn = jest.fn() + const unsubscribe = store.subscribe(fn) + + store.actions.setPostText(root, 'hello') + + expect(fn).toHaveBeenCalledTimes(1) + const after = store.getState() + expect(after).not.toBe(before) + expect(after.posts[root].text).toBe('hello') + unsubscribe() + }) + + test('listener does not fire on a no-op and state ref is preserved', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const before = store.getState() + const fn = jest.fn() + store.subscribe(fn) + + store.actions.setPostText('does-not-exist', 'hello') + + expect(fn).not.toHaveBeenCalled() + expect(store.getState()).toBe(before) + }) + + test('unsubscribed listeners stop receiving notifications', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const root = rootId(store) + const fn = jest.fn() + const unsubscribe = store.subscribe(fn) + + store.actions.setPostText(root, 'a') + expect(fn).toHaveBeenCalledTimes(1) + unsubscribe() + store.actions.setPostText(root, 'b') + expect(fn).toHaveBeenCalledTimes(1) + }) + + test('destroy clears subscribers and stops further notifications', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const root = rootId(store) + const fn = jest.fn() + store.subscribe(fn) + + store.destroy() + store.actions.setPostText(root, 'after destroy') + expect(fn).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/ComposerV2/store/__tests__/posts.test.ts b/src/components/ComposerV2/store/__tests__/posts.test.ts new file mode 100644 index 0000000000..bad0adc7b3 --- /dev/null +++ b/src/components/ComposerV2/store/__tests__/posts.test.ts @@ -0,0 +1,111 @@ +import {describe, expect, test} from '@jest/globals' +import {type AtpAgent} from '@atproto/api' + +import {createThreadStore} from '#/components/ComposerV2/store' + +function makeIdGenerator() { + let i = 0 + return () => `id-${++i}` +} + +const agent = {} as AtpAgent + +function rootId(store: ReturnType) { + return Object.keys(store.getState().posts)[0] +} + +describe('addPost("after")', () => { + test('inserts a new post immediately after the target and returns its id', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const root = rootId(store) + expect(root).toBe('id-1') + + const second = store.actions.addPost('after', root) + expect(second).toBe('id-2') + expect(Object.keys(store.getState().posts)).toEqual(['id-1', 'id-2']) + }) + + test('inserts mid-thread without disturbing surrounding order', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const root = rootId(store) + const second = store.actions.addPost('after', root) // id-2 + const third = store.actions.addPost('after', second) // id-3 + const between = store.actions.addPost('after', root) // id-4 + expect(Object.keys(store.getState().posts)).toEqual([ + root, + between, + second, + third, + ]) + }) + + test('marks state dirty', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + expect(store.getState().isDirty).toBe(false) + store.actions.addPost('after', rootId(store)) + expect(store.getState().isDirty).toBe(true) + }) + + test('is a no-op when the target id is unknown', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const before = store.getState() + store.actions.addPost('after', 'does-not-exist') + expect(store.getState()).toBe(before) + expect(Object.keys(store.getState().posts).length).toBe(1) + }) +}) + +describe('addPost("before")', () => { + test('inserts a new post immediately before the target and returns its id', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const root = rootId(store) + const newId = store.actions.addPost('before', root) + expect(newId).toBe('id-2') + expect(Object.keys(store.getState().posts)).toEqual([newId, root]) + }) + + test('inserts mid-thread without disturbing surrounding order', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const a = rootId(store) + const b = store.actions.addPost('after', a) + const c = store.actions.addPost('after', b) + const before = store.actions.addPost('before', c) + expect(Object.keys(store.getState().posts)).toEqual([a, b, before, c]) + }) + + test('is a no-op when the target id is unknown', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const before = store.getState() + store.actions.addPost('before', 'does-not-exist') + expect(store.getState()).toBe(before) + }) +}) + +describe('removePost', () => { + test('removes the matching post and marks dirty', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const a = rootId(store) + const b = store.actions.addPost('after', a) + store.actions.removePost(b) + expect(Object.keys(store.getState().posts)).toEqual([a]) + expect(store.getState().isDirty).toBe(true) + }) + + test('refuses to remove the last remaining post', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + const a = rootId(store) + const before = store.getState() + store.actions.removePost(a) + expect(store.getState()).toBe(before) + expect(Object.keys(store.getState().posts).length).toBe(1) + }) + + test('is a no-op when postId is unknown', () => { + const store = createThreadStore({agent, __createId: makeIdGenerator()}) + store.actions.addPost('after', rootId(store)) + const before = store.getState() + store.actions.removePost('does-not-exist') + expect(store.getState()).toBe(before) + expect(Object.keys(store.getState().posts).length).toBe(2) + }) +}) diff --git a/src/components/ComposerV2/store/index.ts b/src/components/ComposerV2/store/index.ts new file mode 100644 index 0000000000..d998a4dbe8 --- /dev/null +++ b/src/components/ComposerV2/store/index.ts @@ -0,0 +1,302 @@ +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' + +type Listener = () => void + +export function createThreadStore(options: { + agent: AtpAgent + /** Override id generation; useful for deterministic tests. */ + __createId?: () => string +}) { + const id = options.__createId ?? nanoid + const agent = options.agent + let state: types.ThreadState = { + posts: {[id()]: createEmptyThreadPost()}, + isDirty: false, + draftId: undefined, + } + + const listeners = new Set() + let destroyed = false + + /** + * In-flight upload tasks keyed by media id. Held outside of state because + * cancellation handles aren't serializable. Cleared on terminal status + * (uploaded/failed) and on store destroy. + */ + const uploadTasks = new Map() + + /** + * Action bodies mutate `s` in place. Returning `null` signals a no-op (the + * state ref is preserved and listeners are not notified). Otherwise we + * 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, + ) { + if (destroyed) return + const next = fn(state) + if (next === null) return + state = {...next} + for (const listener of listeners) listener() + } + + /** + * Actions + */ + + function setPostText(postId: string, text: string) { + mutateState(s => { + const post = s.posts[postId] + if (!post) return null + s.posts[postId] = {...post, text} + s.isDirty = true + return s + }) + } + + function setPostLanguages(postId: string, languages: string[]) { + mutateState(s => { + const post = s.posts[postId] + if (!post) return null + s.posts[postId] = {...post, langs: languages} + s.isDirty = true + return s + }) + } + + function setPostLabels(postId: string, labels: string[]) { + mutateState(s => { + const post = s.posts[postId] + if (!post) return null + s.posts[postId] = {...post, labels} + s.isDirty = true + return s + }) + } + + function addPost(position: 'before' | 'after', postId: string): string { + const newId = id() + mutateState(s => { + if (!(postId in s.posts)) return null + // Object key order is insertion order, so to insert mid-thread we + // rebuild the posts object. + const next: Record = {} + for (const [k, v] of Object.entries(s.posts)) { + if (position === 'before' && k === postId) { + next[newId] = createEmptyThreadPost() + } + next[k] = v + if (position === 'after' && k === postId) { + next[newId] = createEmptyThreadPost() + } + } + s.posts = next + s.isDirty = true + return s + }) + return newId + } + + function removePost(postId: string) { + mutateState(s => { + // The composer always has at least one post. + if (Object.keys(s.posts).length <= 1) return null + if (!(postId in s.posts)) return null + // Cancel any in-flight uploads for media on this post before dropping it. + for (const m of s.posts[postId].media) cancelUploadTask(m.id) + delete s.posts[postId] + s.isDirty = true + return s + }) + } + + function queueImageUpload( + postId: string, + input: { + uri: string + width: number + height: number + altText?: string + }, + ): string | undefined { + if (!(postId in state.posts)) return undefined + const imageId = id() + 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] = {...post, media: [...post.media, item]} + s.isDirty = true + return s + }) + uploadTasks.set( + imageId, + startImageUpload({ + postId, + mediaId: imageId, + uri: input.uri, + agent, + setUploadStatus, + }), + ) + return imageId + } + + function removeImage(postId: string, imageId: string) { + cancelUploadTask(imageId) + mutateState(s => { + const post = s.posts[postId] + if (!post) return null + const next = post.media.filter(m => m.id !== imageId) + if (next.length === post.media.length) return null + s.posts[postId] = {...post, media: 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) { + 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.altText === altText) return m + changed = true + return {...m, altText} + }) + if (!changed) return null + s.posts[postId] = {...post, media} + s.isDirty = true + return s + }) + } + + /** + * Public so the simulated upload worker can push progress in. Real callers + * should not invoke this directly; use queueImageUpload / retryImageUpload. + * + * Failed inputs are wrapped here with a `retry()` method bound to this + * (postId, mediaId) so consumers reading the status from state can retry + * without having to look up the ids themselves. + */ + function setUploadStatus( + postId: string, + mediaId: string, + statusInput: types.UploadStatus, + ) { + const status: types.PostMediaUploadStatus = + statusInput.state === 'failed' + ? {...statusInput, retry: () => retryImageUpload(postId, mediaId)} + : statusInput + + mutateState(s => { + const post = s.posts[postId] + if (!post) return null + const idx = post.media.findIndex(m => m.id === mediaId) + if (idx === -1) return null + const found = post.media[idx] + // Gifs don't have an upload lifecycle. + if (found.kind === 'gif') return null + const media = post.media.slice() + media[idx] = {...found, upload: status} + s.posts[postId] = {...post, media} + // Upload progress isn't a user edit, so don't mark dirty here. + return s + }) + if (status.state === 'uploaded' || status.state === 'failed') { + uploadTasks.delete(mediaId) + } + } + + function cancelUploadTask(mediaId: string) { + const task = uploadTasks.get(mediaId) + if (task) { + task.cancel() + uploadTasks.delete(mediaId) + } + } + + return { + actions: { + setPostText, + setPostLanguages, + setPostLabels, + addPost, + removePost, + queueImageUpload, + removeImage, + retryImageUpload, + setImageAltText, + setUploadStatus, + }, + destroy() { + destroyed = true + for (const task of uploadTasks.values()) task.cancel() + uploadTasks.clear() + }, + getState() { + return state + }, + subscribe(listener: Listener) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + } +} + +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 new file mode 100644 index 0000000000..a0cb334344 --- /dev/null +++ b/src/components/ComposerV2/store/types.ts @@ -0,0 +1,115 @@ +import {type AppBskyFeedDefs, type BlobRef} from '@atproto/api' + +import {type Gif} from '#/state/queries/tenor' + +/** + * What an upload reporter (the worker, or a test) sends in. Failed inputs + * carry just the error string; the store wraps the failure with a bound + * `retry()` method when it stores the status. + */ +export type UploadStatus = + | {state: 'pending'} + | {state: 'uploading'; progress: number} + | {state: 'uploaded'; blob: BlobRef} + | {state: 'failed'; error: string} + +/** + * What's stored on a media item. The failed variant has a bound `retry()` so + * UI can call it directly without having to look up postId/mediaId. + * + * Note: `retry` is a function reference and won't survive JSON serialization. + * On restore (OS-resume / draft load), the store re-attaches it. + */ +export type PostMediaUploadStatus = + | {state: 'pending'} + | {state: 'uploading'; progress: number} + | {state: 'uploaded'; blob: BlobRef} + | {state: 'failed'; error: string; retry: () => void} + +export type PostEmbedMediaImage = { + id: string + /** Id of the post this media is attached to. */ + postId: string + uri: string + width: number + height: number + altText: string + /** + * Stable path used to round-trip through saved drafts without re-copying + * bytes. Set when loaded from a draft, or generated at draft-save time for + * media that was added during this composer session. + */ + localRefPath?: string + upload: PostMediaUploadStatus +} + +export type PostEmbedMediaVideo = { + id: string + /** Id of the post this media is attached to. */ + postId: string + uri: string + width: number + height: number + altText: string + mimeType: string + /** See PostEmbedMediaImage.localRefPath. */ + localRefPath?: string + captions: Array<{lang: string; content: string}> + upload: PostMediaUploadStatus +} + +export type PostEmbedMediaGif = { + id: string + /** Id of the post this media is attached to. */ + postId: string + gif: Gif + altText: string +} + +/** + * A single piece of embedded media. A post's `media` is an array of these. + * The bsky semantics (up to 4 images OR 1 video OR 1 gif, never mixed) are + * enforced by the actions that mutate the array, not by this type. + */ +export type PostEmbedMedia = + | (PostEmbedMediaImage & {kind: 'image'}) + | (PostEmbedMediaVideo & {kind: 'video'}) + | (PostEmbedMediaGif & {kind: 'gif'}) + +export type PostEmbedExternal = { + uri: string +} + +export type PostEmbedQuote = { + uri: string + cid: string +} + +export type ThreadPost = { + text: string + langs: string[] + labels: string[] + media: PostEmbedMedia[] + external: PostEmbedExternal | undefined + quote: PostEmbedQuote | undefined +} + +export type ThreadReplyTo = { + uri: string + cid: string + authorDid: string + view?: AppBskyFeedDefs.PostView +} + +export type ThreadState = { + /** + * Posts keyed by id. Insertion order is the thread order; rely on object + * key insertion-order semantics for ES2015+. Keys are nanoid strings so + * they will not be coerced into the integer-key bucket that re-sorts. + */ + posts: Record + /** ID of the saved draft this composer was opened from, if any. */ + draftId: string | undefined + /** True when local state has diverged from the loaded draft (or initial open state). */ + isDirty: boolean +} diff --git a/src/components/ComposerV2/store/uploads.ts b/src/components/ComposerV2/store/uploads.ts new file mode 100644 index 0000000000..7d089d6929 --- /dev/null +++ b/src/components/ComposerV2/store/uploads.ts @@ -0,0 +1,86 @@ +/** + * Background upload worker for the ComposerV2 store. + * + * Currently simulated with timers so the rest of the store can be wired up and + * tested. Each public function returns an UploadTask whose `cancel()` aborts + * any pending work; this is what the store stores per-media so it can cancel + * an in-flight upload when the user removes or replaces the media. + * + * TODO: replace the simulated progression with real AtpAgent.uploadBlob calls + * (com.atproto.repo.uploadBlob) for images. The public surface here should + * not need to change; the simulation lives entirely inside startImageUpload. + */ +import {type AtpAgent, type BlobRef} from '@atproto/api' + +import {type UploadStatus} from './types' + +export type UploadTask = { + cancel(): void +} + +type StartImageUploadOptions = { + postId: string + mediaId: string + uri: string + agent: AtpAgent + setUploadStatus: ( + postId: string, + mediaId: string, + status: UploadStatus, + ) => void +} + +const IMAGE_PROGRESS_STEPS = [0.25, 0.5, 0.75] +const IMAGE_TICK_MS = 100 + +export function startImageUpload(opts: StartImageUploadOptions): UploadTask { + let cancelled = false + let timeoutId: ReturnType | null = null + let stepIndex = 0 + + function tick() { + timeoutId = null + if (cancelled) return + + if (stepIndex < IMAGE_PROGRESS_STEPS.length) { + opts.setUploadStatus(opts.postId, opts.mediaId, { + state: 'uploading', + progress: IMAGE_PROGRESS_STEPS[stepIndex], + }) + stepIndex += 1 + timeoutId = setTimeout(tick, IMAGE_TICK_MS) + return + } + + opts.setUploadStatus(opts.postId, opts.mediaId, { + state: 'uploaded', + blob: makePlaceholderBlobRef(), + }) + } + + timeoutId = setTimeout(tick, IMAGE_TICK_MS) + + return { + cancel() { + if (cancelled) return + cancelled = true + if (timeoutId !== null) { + clearTimeout(timeoutId) + timeoutId = null + } + }, + } +} + +/** + * Placeholder until the real upload returns a BlobRef from the server. + * Real code path will overwrite this entirely. + */ +function makePlaceholderBlobRef(): BlobRef { + return { + $type: 'blob', + ref: {$link: 'simulated-upload-placeholder'}, + mimeType: 'application/octet-stream', + size: 0, + } as unknown as BlobRef +}