checkpoint before media unification

This commit is contained in:
Eric Bailey
2026-05-01 15:37:11 +01:00
parent 7577933378
commit e3ba1c7c4c
12 changed files with 2109 additions and 0 deletions
@@ -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)
})
})
@@ -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()
})
})
+320
View File
@@ -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<NewImageInput & {id: string; localRefPath: string}>},
): 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}
}
+242
View File
@@ -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<GifItem, 'id'>): 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<Listener>()
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<A>(
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}
}
+105
View File
@@ -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
}
+93
View File
@@ -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
}
@@ -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<typeof createThreadStore>) {
return Object.keys(store.getState().posts)[0]
}
function getMedia(
store: ReturnType<typeof createThreadStore>,
postId: string,
): PostEmbedMedia[] {
return store.getState().posts[postId].media
}
const sampleImage = () => ({
uri: 'file:///tmp/a.jpg',
width: 100,
height: 100,
})
beforeEach(() => {
jest.useFakeTimers()
})
describe('queueImageUpload', () => {
test('appends an image with pending upload status and returns its id', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const imageId = store.actions.queueImageUpload(root, sampleImage())
expect(imageId).toBe('id-2')
const media = getMedia(store, root)
expect(media).toHaveLength(1)
expect(media[0].kind).toBe('image')
expect(media[0].id).toBe('id-2')
expect(media[0].kind === 'image' && media[0].upload).toEqual({
state: 'pending',
})
})
test('marks state dirty', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
expect(store.getState().isDirty).toBe(false)
store.actions.queueImageUpload(rootId(store), sampleImage())
expect(store.getState().isDirty).toBe(true)
})
test('attaches the post id to the new media item', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const imageId = store.actions.queueImageUpload(root, sampleImage())!
const media = getMedia(store, root).find(m => m.id === imageId)!
expect(media.postId).toBe(root)
})
test('returns undefined and is a no-op when post id is unknown', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const before = store.getState()
const result = store.actions.queueImageUpload('does-not-exist', sampleImage())
expect(result).toBeUndefined()
expect(store.getState()).toBe(before)
})
test('drives upload from pending -> uploading -> uploaded', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const imageId = store.actions.queueImageUpload(root, sampleImage())!
const get = () => {
const m = getMedia(store, root).find(x => x.id === imageId)!
if (m.kind !== 'image') throw new Error('expected image')
return m.upload
}
expect(get().state).toBe('pending')
jest.advanceTimersByTime(100)
expect(get().state).toBe('uploading')
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('multiple queued images coexist on the same post', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.queueImageUpload(root, sampleImage())
store.actions.queueImageUpload(root, sampleImage())
expect(getMedia(store, root)).toHaveLength(2)
})
})
describe('removeImage', () => {
test('removes the image from media and leaves other media intact', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const a = store.actions.queueImageUpload(root, sampleImage())!
const b = store.actions.queueImageUpload(root, sampleImage())!
store.actions.removeImage(root, a)
const media = getMedia(store, root)
expect(media.map(m => m.id)).toEqual([b])
})
test('cancels in-flight upload (no further status writes after removal)', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const imageId = store.actions.queueImageUpload(root, sampleImage())!
// Let one tick of progress happen, then remove.
jest.advanceTimersByTime(100)
store.actions.removeImage(root, imageId)
// Drain the rest of the simulated upload; no media should reappear.
expect(() => jest.runAllTimers()).not.toThrow()
expect(getMedia(store, root)).toHaveLength(0)
})
test('is a no-op when image id is unknown', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.queueImageUpload(root, sampleImage())
const before = store.getState()
store.actions.removeImage(root, 'does-not-exist')
expect(store.getState()).toBe(before)
})
})
describe('retryImageUpload', () => {
test('resets a failed upload back to pending and walks it through to uploaded', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const imageId = store.actions.queueImageUpload(root, sampleImage())!
// Simulate a failure by overriding status directly.
store.actions.setUploadStatus(root, imageId, {
state: 'failed',
error: 'boom',
})
const get = () => {
const m = getMedia(store, root).find(x => x.id === imageId)!
if (m.kind !== 'image') throw new Error('expected image')
return m.upload
}
expect(get().state).toBe('failed')
store.actions.retryImageUpload(root, imageId)
expect(get().state).toBe('pending')
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('failed status carries a bound retry() method that restarts the upload', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const imageId = store.actions.queueImageUpload(root, sampleImage())!
store.actions.setUploadStatus(root, imageId, {
state: 'failed',
error: 'network',
})
const get = () => {
const m = getMedia(store, root).find(x => x.id === imageId)!
if (m.kind !== 'image') throw new Error('expected image')
return m.upload
}
const failed = get()
if (failed.state !== 'failed') throw new Error('expected failed')
expect(typeof failed.retry).toBe('function')
failed.retry()
expect(get().state).toBe('pending')
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('cancels an existing in-flight task before starting a new one', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const imageId = store.actions.queueImageUpload(root, sampleImage())!
// Partially advance the original task.
jest.advanceTimersByTime(100)
store.actions.retryImageUpload(root, imageId)
const get = () => {
const m = getMedia(store, root).find(x => x.id === imageId)!
if (m.kind !== 'image') throw new Error('expected image')
return m.upload
}
expect(get().state).toBe('pending')
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('is a no-op when post or image id is unknown', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.queueImageUpload(root, sampleImage())
const before = store.getState()
store.actions.retryImageUpload(root, 'does-not-exist')
expect(store.getState()).toBe(before)
store.actions.retryImageUpload('nope', 'whatever')
expect(store.getState()).toBe(before)
})
})
describe('setImageAltText', () => {
test('updates only the matching image', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const a = store.actions.queueImageUpload(root, sampleImage())!
const b = store.actions.queueImageUpload(root, sampleImage())!
store.actions.setImageAltText(root, b, 'a description')
const media = getMedia(store, root)
expect(media.find(m => m.id === a)?.kind === 'image' && media.find(m => m.id === a)).toMatchObject({altText: ''})
expect(media.find(m => m.id === b)?.kind === 'image' && media.find(m => m.id === b)).toMatchObject({altText: 'a description'})
})
})
describe('removePost cancels media uploads', () => {
test('removing a post cancels any in-flight uploads on that post', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const a = rootId(store)
const b = store.actions.addPost('after', a)
store.actions.queueImageUpload(b, sampleImage())
jest.advanceTimersByTime(100)
store.actions.removePost(b)
// Draining timers should not crash and the removed post stays gone.
expect(() => jest.runAllTimers()).not.toThrow()
expect(Object.keys(store.getState().posts)).toEqual([a])
})
})
describe('destroy cancels uploads', () => {
test('destroy stops any in-flight uploads', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
store.actions.queueImageUpload(root, sampleImage())
jest.advanceTimersByTime(100)
store.destroy()
expect(() => jest.runAllTimers()).not.toThrow()
})
})
@@ -0,0 +1,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<typeof createThreadStore>) {
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()
})
})
@@ -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<typeof createThreadStore>) {
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)
})
})
+302
View File
@@ -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<Listener>()
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<string, UploadTask>()
/**
* 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<string, types.ThreadPost> = {}
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,
}
}
+115
View File
@@ -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<string, ThreadPost>
/** 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
}
@@ -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<typeof setTimeout> | 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
}