diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 08d4cb9629..51bf51ffff 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -24,6 +24,7 @@ import { threadgateAllowUISettingToAllowRecordValue, writeThreadgateRecord, } from '#/state/queries/threadgate' +import {ComposerState} from '#/view/com/composer/state' import {LinkMeta} from '../link-meta/link-meta' import {uploadBlob} from './upload-blob' @@ -38,6 +39,7 @@ export interface ExternalEmbedDraft { } interface PostOpts { + composerState: ComposerState // TODO: Not used yet. rawText: string replyTo?: string quote?: { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index ade37af1b6..f354f0f0dc 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useImperativeHandle, useMemo, + useReducer, useRef, useState, } from 'react' @@ -66,7 +67,7 @@ import {logger} from '#/logger' import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection' import {useDialogStateControlContext} from '#/state/dialogs' import {emitPostCreated} from '#/state/events' -import {ComposerImage, createInitialImages, pasteImage} from '#/state/gallery' +import {ComposerImage, pasteImage} from '#/state/gallery' import {useModalControls} from '#/state/modals' import {useModals} from '#/state/modals' import {useRequireAltTextEnabled} from '#/state/preferences' @@ -119,6 +120,7 @@ import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import * as Prompt from '#/components/Prompt' import {Text as NewText} from '#/components/Typography' +import {composerReducer, createComposerState} from './state' const MAX_IMAGES = 4 @@ -126,6 +128,8 @@ type CancelRef = { onPressCancel: () => void } +const NO_IMAGES: ComposerImage[] = [] + type Props = ComposerOpts export const ComposePost = ({ replyTo, @@ -213,9 +217,17 @@ export const ComposePost = ({ ) const [postgate, setPostgate] = useState(createPostgateRecord({post: ''})) - const [images, setImages] = useState(() => - createInitialImages(initImageUris), + // TODO: Move more state here. + const [composerState, dispatch] = useReducer( + composerReducer, + {initImageUris}, + createComposerState, ) + let images = NO_IMAGES + if (composerState.embed.media?.type === 'images') { + images = composerState.embed.media.images + } + const onClose = useCallback(() => { closeComposer() }, [closeComposer]) @@ -301,9 +313,12 @@ export const ComposePost = ({ const onImageAdd = useCallback( (next: ComposerImage[]) => { - setImages(prev => prev.concat(next.slice(0, MAX_IMAGES - prev.length))) + dispatch({ + type: 'embed_add_images', + images: next, + }) }, - [setImages], + [dispatch], ) const onPhotoPasted = useCallback( @@ -374,6 +389,7 @@ export const ComposePost = ({ try { postUri = ( await apilib.post(agent, { + composerState, // TODO: not used yet. rawText: richtext.text, replyTo: replyTo?.uri, images, @@ -475,6 +491,7 @@ export const ComposePost = ({ _, agent, captions, + composerState, extLink, images, graphemeLength, @@ -717,7 +734,7 @@ export const ComposePost = ({ /> - + {images.length === 0 && extLink && ( void + dispatch: (action: ComposerAction) => void } export let Gallery = (props: GalleryProps): React.ReactNode => { @@ -56,7 +57,7 @@ interface GalleryInnerProps extends GalleryProps { containerInfo: Dimensions } -const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => { +const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => { const {isMobile} = useWebMediaQueries() const {altTextControlStyle, imageControlsStyle, imageStyle} = @@ -96,7 +97,7 @@ const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => { return images.length !== 0 ? ( <> - {images.map((image, index) => { + {images.map(image => { return ( { imageControlsStyle={imageControlsStyle} imageStyle={imageStyle} onChange={next => { - onChange( - images.map(i => (i.source === image.source ? next : i)), - ) + dispatch({type: 'embed_update_image', image: next}) }} onRemove={() => { - const next = images.slice() - next.splice(index, 1) - - onChange(next) + dispatch({type: 'embed_remove_image', image}) }} /> ) diff --git a/src/view/com/composer/state.ts b/src/view/com/composer/state.ts new file mode 100644 index 0000000000..5588de1aa3 --- /dev/null +++ b/src/view/com/composer/state.ts @@ -0,0 +1,131 @@ +import {ComposerImage, createInitialImages} from '#/state/gallery' +import {ComposerOpts} from '#/state/shell/composer' + +type PostRecord = { + uri: string +} + +type ImagesMedia = { + type: 'images' + images: ComposerImage[] + labels: string[] +} + +type ComposerEmbed = { + // TODO: Other record types. + record: PostRecord | undefined + // TODO: Other media types. + media: ImagesMedia | undefined +} + +export type ComposerState = { + // TODO: Other draft data. + embed: ComposerEmbed +} + +export type ComposerAction = + | {type: 'embed_add_images'; images: ComposerImage[]} + | {type: 'embed_update_image'; image: ComposerImage} + | {type: 'embed_remove_image'; image: ComposerImage} + +const MAX_IMAGES = 4 + +export function composerReducer( + state: ComposerState, + action: ComposerAction, +): ComposerState { + switch (action.type) { + case 'embed_add_images': { + const prevMedia = state.embed.media + let nextMedia = prevMedia + if (!prevMedia) { + nextMedia = { + type: 'images', + images: action.images.slice(0, MAX_IMAGES), + labels: [], + } + } else if (prevMedia.type === 'images') { + nextMedia = { + ...prevMedia, + images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES), + } + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } + case 'embed_update_image': { + const prevMedia = state.embed.media + if (prevMedia?.type === 'images') { + const updatedImage = action.image + const nextMedia = { + ...prevMedia, + images: prevMedia.images.map(img => { + if (img.source.id === updatedImage.source.id) { + return updatedImage + } + return img + }), + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } + return state + } + case 'embed_remove_image': { + const prevMedia = state.embed.media + if (prevMedia?.type === 'images') { + const removedImage = action.image + let nextMedia: ImagesMedia | undefined = { + ...prevMedia, + images: prevMedia.images.filter(img => { + return img.source.id !== removedImage.source.id + }), + } + if (nextMedia.images.length === 0) { + nextMedia = undefined + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } + return state + } + default: + return state + } +} + +export function createComposerState({ + initImageUris, +}: { + initImageUris: ComposerOpts['imageUris'] +}): ComposerState { + let media: ImagesMedia | undefined + if (initImageUris?.length) { + media = { + type: 'images', + images: createInitialImages(initImageUris), + labels: [], + } + } + return { + embed: { + record: undefined, + media, + }, + } +}