From d520dd95b9aaba4850645382a71cb38bb2c2d23a Mon Sep 17 00:00:00 2001 From: dan Date: Fri, 25 Oct 2024 21:36:54 +0100 Subject: Split composer into smaller components (#5941) * Extract ComposerTopBar * Rename state variables to align with props * Extract ComposerEmbeds * Extract ComposerPills * Extract ComposerFooter * Tweak condition to be simpler * Extract ComposerPost --- src/view/com/composer/Composer.tsx | 866 ++++++++++++--------- src/view/com/composer/text-input/TextInput.tsx | 2 +- src/view/com/composer/text-input/TextInput.web.tsx | 10 +- 3 files changed, 521 insertions(+), 357 deletions(-) (limited to 'src') diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 249ba99e5..a523c3f52 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -42,6 +42,7 @@ import { AppBskyFeedDefs, AppBskyFeedGetPostThread, BskyAgent, + RichText, } from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' @@ -112,8 +113,11 @@ import * as Prompt from '#/components/Prompt' import {Text as NewText} from '#/components/Typography' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' import { + ComposerAction, + ComposerDraft, composerReducer, createComposerState, + EmbedDraft, MAX_IMAGES, } from './state/composer' import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video' @@ -142,10 +146,7 @@ export const ComposePost = ({ const agent = useAgent() const queryClient = useQueryClient() const currentDid = currentAccount!.did - const {data: currentProfile} = useProfileQuery({did: currentDid}) const {closeComposer} = useComposerControls() - const pal = usePalette('default') - const {isMobile} = useWebMediaQueries() const {_} = useLingui() const requireAltTextEnabled = useRequireAltTextEnabled() const langPrefs = useLanguagePrefs() @@ -154,11 +155,10 @@ export const ComposePost = ({ const discardPromptControl = Prompt.usePromptControl() const {closeAllDialogs} = useDialogStateControlContext() const {closeAllModals} = useModalControls() - const t = useTheme() const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) - const [isProcessing, setIsProcessing] = useState(false) - const [processingState, setProcessingState] = useState('') + const [isPublishing, setIsPublishing] = useState(false) + const [publishingStage, setPublishingStage] = useState('') const [error, setError] = useState('') const [draft, dispatch] = useReducer( @@ -222,22 +222,6 @@ export const ComposePost = ({ dispatch({type: 'embed_remove_video'}) }, [videoState.abortController, dispatch]) - const updateVideoDimensions = useCallback( - (width: number, height: number) => { - dispatch({ - type: 'embed_update_video', - videoAction: { - type: 'update_dimensions', - width, - height, - signal: videoState.abortController.signal, - }, - }) - }, - [videoState.abortController], - ) - - const hasVideo = Boolean(videoState.asset || videoState.video) const [publishOnUpload, setPublishOnUpload] = useState(false) const onClose = useCallback(() => { @@ -299,32 +283,6 @@ export const ComposePost = ({ } }, [onPressCancel, closeAllDialogs, closeAllModals]) - const onNewLink = useCallback((uri: string) => { - dispatch({type: 'embed_add_uri', uri}) - }, []) - - const onImageAdd = useCallback( - (next: ComposerImage[]) => { - dispatch({ - type: 'embed_add_images', - images: next, - }) - }, - [dispatch], - ) - - const onPhotoPasted = useCallback( - async (uri: string) => { - if (uri.startsWith('data:video/')) { - selectVideo({uri, type: 'video', height: 0, width: 0}) - } else { - const res = await pasteImage(uri) - onImageAdd([res]) - } - }, - [selectVideo, onImageAdd], - ) - const isAltTextRequiredAndMissing = useMemo(() => { if (!requireAltTextEnabled) return false @@ -336,8 +294,8 @@ export const ComposePost = ({ }, [images, extGifAlt, extGif, requireAltTextEnabled]) const onPressPublish = React.useCallback( - async (finishedUploading?: boolean) => { - if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { + async (finishedUploading: boolean) => { + if (isPublishing || graphemeLength > MAX_GRAPHEME_LENGTH) { return } @@ -368,7 +326,7 @@ export const ComposePost = ({ return } - setIsProcessing(true) + setIsPublishing(true) let postUri try { @@ -376,7 +334,7 @@ export const ComposePost = ({ await apilib.post(agent, queryClient, { draft: draft, replyTo: replyTo?.uri, - onStateChange: setProcessingState, + onStateChange: setPublishingStage, langs: toPostLanguages(langPrefs.postLanguage), }) ).uri @@ -406,7 +364,7 @@ export const ComposePost = ({ err = _(msg`This post's author has disabled quote posts.`) } setError(err) - setIsProcessing(false) + setIsPublishing(false) return } finally { if (postUri) { @@ -456,7 +414,7 @@ export const ComposePost = ({ images, graphemeLength, isAltTextRequiredAndMissing, - isProcessing, + isPublishing, langPrefs.postLanguage, onClose, onPost, @@ -484,28 +442,11 @@ export const ComposePost = ({ () => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing, [graphemeLength, isAltTextRequiredAndMissing], ) - const selectTextInputPlaceholder = replyTo - ? _(msg`Write your reply`) - : _(msg`What's up?`) - - const canSelectImages = - images.length < MAX_IMAGES && - videoState.status === 'idle' && - !videoState.video - const hasMedia = images.length > 0 || Boolean(videoState.video) const onEmojiButtonPress = useCallback(() => { openEmojiPicker?.(textInput.current?.getCursorPosition()) }, [openEmojiPicker]) - const onSelectGif = useCallback((gif: Gif) => { - dispatch({type: 'embed_add_gif', gif}) - }, []) - - const handleChangeGifAltText = useCallback((altText: string) => { - dispatch({type: 'embed_update_gif', alt: altText}) - }, []) - const { scrollHandler, onScrollViewContentSizeChange, @@ -527,86 +468,24 @@ export const ComposePost = ({ style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal> - - - - - {isProcessing ? ( - <> - {processingState} - - - - - ) : canPost ? ( - - ) : ( - - - Post - - - )} - - - {isAltTextRequiredAndMissing && ( - - - - - - One or more images is missing alt text. - - - )} + onPressPublish(false)}> + {isAltTextRequiredAndMissing && } setError('')} clearVideo={clearVideo} /> - + + {replyTo ? : undefined} + onPressPublish(false)} + onError={setError} + /> + - - - { - dispatch({type: 'update_richtext', richtext: rt}) - }} - onPhotoPasted={onPhotoPasted} - onPressPublish={() => onPressPublish()} - onNewLink={onNewLink} - onError={setError} - accessible={true} - accessibilityLabel={_(msg`Write post`)} - accessibilityHint={_( - msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`, - )} - /> - + - + + + + - {extGif && ( - - { - dispatch({type: 'embed_remove_gif'}) - }} - /> - - - )} + + + + ) +} - {!draft.embed.media && extLink && ( - - { - dispatch({type: 'embed_remove_link'}) - }} - /> - - )} +function ComposerPost({ + draft, + dispatch, + textInput, + isReply, + canRemoveQuote, + onClearVideo, + onSelectVideo, + onError, + onPublish, +}: { + draft: ComposerDraft + dispatch: (action: ComposerAction) => void + textInput: React.Ref + isReply: boolean + canRemoveQuote: boolean + onClearVideo: () => void + onSelectVideo: (asset: ImagePickerAsset) => void + onError: (error: string) => void + onPublish: (richtext: RichText) => void +}) { + const {currentAccount} = useSession() + const currentDid = currentAccount!.did + const {_} = useLingui() + const {data: currentProfile} = useProfileQuery({did: currentDid}) + const richtext = draft.richtext + const selectTextInputPlaceholder = isReply + ? _(msg`Write your reply`) + : _(msg`What's up?`) - - {hasVideo && ( - - {videoState.asset && - (videoState.status === 'compressing' ? ( - - ) : videoState.video ? ( - - ) : null)} - - dispatch({ - type: 'embed_update_video', - videoAction: { - type: 'update_alt_text', - altText, - signal: videoState.abortController.signal, - }, - }) - } - captions={videoState.captions} - setCaptions={updater => { - dispatch({ - type: 'embed_update_video', - videoAction: { - type: 'update_captions', - updater, - signal: videoState.abortController.signal, - }, - }) - }} - /> - - )} - - - {quote ? ( - - - - - {!initQuote && ( - { - dispatch({type: 'embed_remove_quote'}) - }} - /> - )} - - ) : null} + const onImageAdd = useCallback( + (next: ComposerImage[]) => { + dispatch({ + type: 'embed_add_images', + images: next, + }) + }, + [dispatch], + ) + + const onNewLink = useCallback( + (uri: string) => { + dispatch({type: 'embed_add_uri', uri}) + }, + [dispatch], + ) + + const onPhotoPasted = useCallback( + async (uri: string) => { + if (uri.startsWith('data:video/')) { + onSelectVideo({uri, type: 'video', height: 0, width: 0}) + } else { + const res = await pasteImage(uri) + onImageAdd([res]) + } + }, + [onSelectVideo, onImageAdd], + ) + + return ( + <> + + + { + dispatch({type: 'update_richtext', richtext: rt}) + }} + onPhotoPasted={onPhotoPasted} + onNewLink={onNewLink} + onError={onError} + onPressPublish={onPublish} + accessible={true} + accessibilityLabel={_(msg`Write post`)} + accessibilityHint={_( + msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`, + )} + /> + + + + + ) +} + +function ComposerTopBar({ + canPost, + isReply, + isPublishQueued, + isPublishing, + publishingStage, + onCancel, + onPublish, + topBarAnimatedStyle, + children, +}: { + isPublishing: boolean + publishingStage: string + canPost: boolean + isReply: boolean + isPublishQueued: boolean + onCancel: () => void + onPublish: () => void + topBarAnimatedStyle: StyleProp + children?: React.ReactNode +}) { + const pal = usePalette('default') + return ( + + + + + {isPublishing ? ( + <> + {publishingStage} + + - - + + ) : canPost ? ( + + ) : ( + + + Post + + + )} + + {children} + + ) +} + +function AltTextReminder() { + const pal = usePalette('default') + return ( + + + + + + One or more images is missing alt text. + + + ) +} + +function ComposerEmbeds({ + embed, + dispatch, + clearVideo, + canRemoveQuote, +}: { + embed: EmbedDraft + dispatch: (action: ComposerAction) => void + clearVideo: () => void + canRemoveQuote: boolean +}) { + const video = embed.media?.type === 'video' ? embed.media.video : null + return ( + <> + {embed.media?.type === 'images' && ( + + )} + + {embed.media?.type === 'gif' && ( + + dispatch({type: 'embed_remove_gif'})} + /> + { + dispatch({type: 'embed_update_gif', alt: altText}) + }} + /> + + )} + + {!embed.media && embed.link && ( + + dispatch({type: 'embed_remove_link'})} + /> + + )} + + {video && ( - - {replyTo ? null : ( - { - dispatch({type: 'update_postgate', postgate: nextPostgate}) - }} - threadgateAllowUISettings={draft.threadgate} - onChangeThreadgateAllowUISettings={nextThreadgate => { + style={[a.w_full, a.mt_lg]} + entering={native(ZoomIn)} + exiting={native(ZoomOut)}> + {video.asset && + (video.status === 'compressing' ? ( + + ) : video.video ? ( + { dispatch({ - type: 'update_threadgate', - threadgate: nextThreadgate, + type: 'embed_update_video', + videoAction: { + type: 'update_dimensions', + width, + height, + signal: video.abortController.signal, + }, }) }} - style={bottomBarAnimatedStyle} + clear={clearVideo} /> - )} - { - dispatch({type: 'update_labels', labels: nextLabels}) - }} - hasMedia={hasMedia || Boolean(extLink)} - /> - + ) : null)} + + dispatch({ + type: 'embed_update_video', + videoAction: { + type: 'update_alt_text', + altText, + signal: video.abortController.signal, + }, + }) + } + captions={video.captions} + setCaptions={updater => { + dispatch({ + type: 'embed_update_video', + videoAction: { + type: 'update_captions', + updater, + signal: video.abortController.signal, + }, + }) + }} + /> - - - {videoState.status !== 'idle' && videoState.status !== 'done' ? ( - - ) : ( - - - 0} - setError={setError} - /> - - - {!isMobile ? ( - - ) : null} - - )} - - - - + )} + + + + {embed.quote?.uri ? ( + + + + {canRemoveQuote && ( + dispatch({type: 'embed_remove_quote'})} /> + )} - - + + ) +} + +function ComposerPills({ + isReply, + draft, + dispatch, + bottomBarAnimatedStyle, +}: { + isReply: boolean + draft: ComposerDraft + dispatch: (action: ComposerAction) => void + bottomBarAnimatedStyle: StyleProp +}) { + const t = useTheme() + const media = draft.embed.media + const hasMedia = media?.type === 'images' || media?.type === 'video' + const hasLink = !!draft.embed.link + return ( + + + {isReply ? null : ( + { + dispatch({type: 'update_postgate', postgate: nextPostgate}) + }} + threadgateAllowUISettings={draft.threadgate} + onChangeThreadgateAllowUISettings={nextThreadgate => { + dispatch({ + type: 'update_threadgate', + threadgate: nextThreadgate, + }) + }} + style={bottomBarAnimatedStyle} + /> + )} + { + dispatch({type: 'update_labels', labels: nextLabels}) + }} + hasMedia={hasMedia || hasLink} /> - - + + + ) +} + +function ComposerFooter({ + draft, + dispatch, + graphemeLength, + onEmojiButtonPress, + onError, + onSelectVideo, +}: { + draft: ComposerDraft + dispatch: (action: ComposerAction) => void + graphemeLength: number + onEmojiButtonPress: () => void + onError: (error: string) => void + onSelectVideo: (asset: ImagePickerAsset) => void +}) { + const t = useTheme() + const {_} = useLingui() + const {isMobile} = useWebMediaQueries() + + const media = draft.embed.media + const images = media?.type === 'images' ? media.images : [] + const video = media?.type === 'video' ? media.video : null + const isMaxImages = images.length >= MAX_IMAGES + + const onImageAdd = useCallback( + (next: ComposerImage[]) => { + dispatch({ + type: 'embed_add_images', + images: next, + }) + }, + [dispatch], + ) + + const onSelectGif = useCallback( + (gif: Gif) => { + dispatch({type: 'embed_add_gif', gif}) + }, + [dispatch], + ) + + return ( + + + {video && video.status !== 'done' ? ( + + ) : ( + + + + + + {!isMobile ? ( + + ) : null} + + )} + + + + + + ) } diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 43074fa5b..11bbf13d2 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -45,7 +45,7 @@ interface TextInputProps extends ComponentProps { placeholder: string setRichText: (v: RichText) => void onPhotoPasted: (uri: string) => void - onPressPublish: (richtext: RichText) => Promise + onPressPublish: (richtext: RichText) => void onNewLink: (uri: string) => void onError: (err: string) => void } diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index acec61516..1d7908e16 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -13,15 +13,15 @@ import {Text as TiptapText} from '@tiptap/extension-text' import {generateJSON} from '@tiptap/html' import {EditorContent, JSONContent, useEditor} from '@tiptap/react' +import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {usePalette} from '#/lib/hooks/usePalette' +import {blobToDataUri, isUriImage} from '#/lib/media/util' import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' -import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' -import {blobToDataUri, isUriImage} from 'lib/media/util' -import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' import { LinkFacetMatch, suggestLinkCardUri, -} from 'view/com/composer/text-input/text-input-util' +} from '#/view/com/composer/text-input/text-input-util' +import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' import {atoms as a, useAlf} from '#/alf' import {Portal} from '#/components/Portal' import {normalizeTextStyles} from '#/components/Typography' @@ -43,7 +43,7 @@ interface TextInputProps { suggestedLinks: Set setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void - onPressPublish: (richtext: RichText) => Promise + onPressPublish: (richtext: RichText) => void onNewLink: (uri: string) => void onError: (err: string) => void } -- cgit 1.4.1