Files
bsky-social-app/src/components/ComposerV2/store/index.ts
T
2026-06-06 10:03:33 -05:00

412 lines
12 KiB
TypeScript

import {type AtpAgent} from '@atproto/api'
import {nanoid} from 'nanoid/non-secure'
import type * as types from '#/components/ComposerV2/store/types'
import {
startImageUpload,
startVideoUpload,
type UploadTask,
} from '#/components/ComposerV2/store/uploads'
import {buildPostMediaItem} from '#/components/ComposerV2/store/utils/buildPostMediaItem'
import {buildThreadPost} from '#/components/ComposerV2/store/utils/buildThreadPost'
import {computePostMediaSelectionsRemaining} from '#/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining'
import {filterMediaInputs} from '#/components/ComposerV2/store/utils/filterMediaInputs'
type Listener = () => void
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()]: buildThreadPost()},
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] = buildThreadPost()
}
next[k] = v
if (position === 'after' && k === postId) {
next[newId] = buildThreadPost()
}
}
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
})
}
/**
* Add one or more media items to a post and start any required uploads.
* Accepts a heterogeneous list (images, video, gif). Gifs don't kick off
* an upload task; images and videos do.
*
* Returns the new media ids in input order, or undefined if the postId
* doesn't exist (no items are added in that case).
*/
function addMedia(
postId: string,
inputs: types.AddMediaInput[],
): string[] | undefined {
if (!(postId in state.posts)) return undefined
if (inputs.length === 0) return []
// External link cards are mutually exclusive with media. This rule is
// permanent (unlike the kind/cap rules in filterMediaInputs) so it lives
// here at the action boundary rather than inside the filter helper.
if (state.posts[postId].external !== undefined) return []
const accepted = filterMediaInputs(state.posts[postId].media, inputs)
if (accepted.length === 0) return []
const newIds = accepted.map(() => id())
const newItems: types.PostEmbedMedia[] = accepted.map((input, i) =>
buildPostMediaItem(input, {id: newIds[i], postId}),
)
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
s.posts[postId] = setPostMedia(post, [...post.media, ...newItems])
s.isDirty = true
return s
})
for (let i = 0; i < accepted.length; i++) {
const input = accepted[i]
const mediaId = newIds[i]
if (input.kind === 'image') {
uploadTasks.set(
mediaId,
startImageUpload({
postId,
mediaId,
uri: input.uri,
agent,
setUploadStatus,
}),
)
} else if (input.kind === 'video') {
uploadTasks.set(
mediaId,
startVideoUpload({
postId,
mediaId,
uri: input.uri,
agent,
setUploadStatus,
}),
)
}
// gif: no upload task
}
return newIds
}
function removeMedia(postId: string, mediaId: string) {
cancelUploadTask(mediaId)
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
const next = post.media.filter(m => m.id !== mediaId)
if (next.length === post.media.length) return null
s.posts[postId] = setPostMedia(post, next)
s.isDirty = true
return s
})
}
function updateMediaAltText(
postId: string,
mediaId: string,
altText: string,
) {
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
let changed = false
const media = post.media.map(m => {
if (m.id !== mediaId) return m
if (m.altText === altText) return m
changed = true
return {...m, altText}
})
if (!changed) return null
s.posts[postId] = setPostMedia(post, media)
s.isDirty = true
return s
})
}
/**
* Restart a failed (or in-flight) upload for an image or video. No-ops on
* a gif (no upload lifecycle) or on unknown ids.
*/
function retryMediaUpload(postId: string, mediaId: string) {
const post = state.posts[postId]
if (!post) return
const item = post.media.find(m => m.id === mediaId)
if (!item) return
if (item.kind === 'gif') return
cancelUploadTask(mediaId)
mutateState(s => {
const p = s.posts[postId]
if (!p) return null
const media = p.media.map(m =>
m.id === mediaId ? {...m, upload: {state: 'pending' as const}} : m,
)
s.posts[postId] = setPostMedia(p, media)
return s
})
const start = item.kind === 'image' ? startImageUpload : startVideoUpload
uploadTasks.set(
mediaId,
start({
postId,
mediaId,
uri: item.uri,
agent,
setUploadStatus,
}),
)
}
function setExternalEmbed(postId: string, external: types.PostEmbedExternal) {
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
s.posts[postId] = setPostExternal(post, external)
s.isDirty = true
return s
})
}
function removeExternalEmbed(postId: string) {
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
if (post.external === undefined) return null
s.posts[postId] = setPostExternal(post, undefined)
s.isDirty = true
return s
})
}
function setQuoteEmbed(postId: string, quote: types.PostEmbedQuote) {
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
s.posts[postId] = {...post, quote}
s.isDirty = true
return s
})
}
function removeQuoteEmbed(postId: string) {
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
if (post.quote === undefined) return null
s.posts[postId] = {...post, quote: undefined}
s.isDirty = true
return s
})
}
/**
* Public so the simulated upload worker can push progress in. Real callers
* should not invoke this directly; use addMedia / retryMediaUpload.
*
* 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: () => retryMediaUpload(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] = setPostMedia(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)
}
}
/**
* Single chokepoint for replacing a post's media array. Recomputes the
* derived selectionsRemaining flags so they never drift from the array.
*/
function setPostMedia(
post: types.ThreadPost,
media: types.PostEmbedMedia[],
): types.ThreadPost {
return {
...post,
media,
...computePostMediaSelectionsRemaining(media, post.external),
}
}
/**
* Single chokepoint for replacing a post's external link card. Mirrors
* setPostMedia so the selectionsRemaining flags stay consistent (an
* external link blocks all media selections).
*/
function setPostExternal(
post: types.ThreadPost,
external: types.PostEmbedExternal | undefined,
): types.ThreadPost {
return {
...post,
external,
...computePostMediaSelectionsRemaining(post.media, external),
}
}
return {
actions: {
setPostText,
setPostLanguages,
setPostLabels,
addPost,
removePost,
addMedia,
removeMedia,
updateMediaAltText,
retryMediaUpload,
setExternalEmbed,
removeExternalEmbed,
setQuoteEmbed,
removeQuoteEmbed,
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)
}
},
}
}