checkpoint before media unification
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user