From a6c547129463b4a72c02f61617c519bed38f6406 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 31 Oct 2025 10:39:59 +0200 Subject: [PATCH] hoist composer state --- src/view/com/composer/Composer.tsx | 77 +++++++------------------ src/view/com/composer/state/composer.ts | 76 +++++++++++++++++++++--- src/view/shell/Composer.ios.tsx | 33 +++++------ src/view/shell/Composer.tsx | 26 ++++++++- src/view/shell/Composer.web.tsx | 40 +++++++++---- 5 files changed, 156 insertions(+), 96 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 7b7d042334..26eb62fe60 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -1,9 +1,10 @@ -import React, { +import { + Fragment, + memo, useCallback, useEffect, useImperativeHandle, useMemo, - useReducer, useRef, useState, } from 'react' @@ -84,7 +85,6 @@ import { createComposerImage, pasteImage, } from '#/state/gallery' -import {useModalControls} from '#/state/modals' import {useRequireAltTextEnabled} from '#/state/preferences' import { fromPostLanguages, @@ -92,7 +92,6 @@ import { useLanguagePrefs, useLanguagePrefsApi, } from '#/state/preferences/languages' -import {usePreferencesQuery} from '#/state/queries/preferences' import {useProfileQuery} from '#/state/queries/profile' import {type Gif} from '#/state/queries/tenor' import {useAgent, useSession} from '#/state/session' @@ -139,8 +138,7 @@ import { } from './SelectMediaButton' import { type ComposerAction, - composerReducer, - createComposerState, + type ComposerState, type EmbedDraft, MAX_IMAGES, type PostAction, @@ -167,16 +165,17 @@ export const ComposePost = ({ onPost, onPostSuccess, quote: initQuote, - mention: initMention, openEmojiPicker, - text: initText, - imageUris: initImageUris, videoUri: initVideoUri, + composerState, + composerDispatch, cancelRef, - setIsDirty, + isDirty, }: Props & { + composerState: ComposerState + composerDispatch: React.Dispatch cancelRef?: React.RefObject - setIsDirty?: React.Dispatch> + isDirty: boolean }) => { const {currentAccount} = useSession() const agent = useAgent() @@ -190,8 +189,6 @@ export const ComposePost = ({ const textInput = useRef(null) const discardPromptControl = Prompt.usePromptControl() const {closeAllDialogs} = useDialogStateControlContext() - const {closeAllModals} = useModalControls() - const {data: preferences} = usePreferencesQuery() const navigation = useNavigation() const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) @@ -237,18 +234,6 @@ export const ComposePost = ({ setReplyToLanguages([]) } - const [composerState, composerDispatch] = useReducer( - composerReducer, - { - initImageUris, - initQuoteUri: initQuote?.uri, - initText, - initMention, - initInteractionSettings: preferences?.postInteractionSettings, - }, - createComposerState, - ) - const thread = composerState.thread const activePost = thread.posts[composerState.activePostIndex] const nextPost: PostDraft | undefined = @@ -261,10 +246,10 @@ export const ComposePost = ({ postAction, }) }, - [activePost.id], + [activePost.id, composerDispatch], ) - const selectVideo = React.useCallback( + const selectVideo = useCallback( (postId: string, asset: ImagePickerAsset) => { const abortController = new AbortController() composerDispatch({ @@ -307,7 +292,7 @@ export const ComposePost = ({ onInitVideo() }, [onInitVideo]) - const clearVideo = React.useCallback( + const clearVideo = useCallback( (postId: string) => { composerDispatch({ type: 'update_post', @@ -344,26 +329,7 @@ export const ComposePost = ({ [insets, isKeyboardVisible], ) - const isDirty = thread.posts.some( - post => - post.shortenedGraphemeLength > 0 || post.embed.media || post.embed.link, - ) - - // very unfortunate, but we need to pass state back up to the parent on iOS - // - // WARNING - if the Modal on iOS thinks it's not dirty, - // `allowSwipeDismissal` will be true, and if we don't then close the composer - // when `onPressCancel` is called it will be bad (might even softlock) - // so we need to keep the parent state and the behaviour of onPressCancel in - // tight sync. do NOT force the modal to stay open without marking it as dirty! -sfn - useEffect(() => { - if (isIOS) { - setIsDirty?.(isDirty) - } - }, [isDirty, setIsDirty]) - const onPressCancel = useNonReactiveCallback(() => { - // web only, so it's fine w.r.t. the Modal const didCloseAutocomplete = textInput.current?.maybeClosePopup() if (isWeb && didCloseAutocomplete) { return @@ -386,7 +352,7 @@ export const ComposePost = ({ const backHandler = BackHandler.addEventListener( 'hardwareBackPress', () => { - if (closeAllDialogs() || closeAllModals()) { + if (closeAllDialogs()) { return true } onPressCancel() @@ -396,7 +362,7 @@ export const ComposePost = ({ return () => { backHandler.remove() } - }, [onPressCancel, closeAllDialogs, closeAllModals]) + }, [onPressCancel, closeAllDialogs]) const missingAltError = useMemo(() => { if (!requireAltTextEnabled) { @@ -434,7 +400,7 @@ export const ComposePost = ({ ), ) - const onPressPublish = React.useCallback(async () => { + const onPressPublish = useCallback(async () => { if (isPublishing) { return } @@ -629,7 +595,7 @@ export const ComposePost = ({ onPressPublish() }) - React.useEffect(() => { + useEffect(() => { if (publishOnUpload) { let erroredVideos = 0 let uploadingVideos = 0 @@ -673,8 +639,7 @@ export const ComposePost = ({ if (rect) { openEmojiPicker?.({ ...rect, - nextFocusRef: - textInput as unknown as React.MutableRefObject, + nextFocusRef: textInput as unknown as React.RefObject, }) } }, [openEmojiPicker]) @@ -786,7 +751,7 @@ export const ComposePost = ({ onLayout={onScrollViewLayout}> {replyTo ? : undefined} {thread.posts.map((post, index) => ( - + {footer} )} - + ))} {!isWebFooterSticky && footer} @@ -825,7 +790,7 @@ export const ComposePost = ({ ) } -let ComposerPost = React.memo(function ComposerPost({ +let ComposerPost = memo(function ComposerPost({ post, dispatch, textInput, diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index c673f21341..c6e8dbf793 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -1,3 +1,4 @@ +import {useReducer, useState} from 'react' import {type ImagePickerAsset} from 'expo-image-picker' import { type AppBskyFeedPostgate, @@ -17,6 +18,7 @@ import { } from '#/lib/strings/url-helpers' import {type ComposerImage, createInitialImages} from '#/state/gallery' import {createPostgateRecord} from '#/state/queries/postgate/util' +import {usePreferencesQuery} from '#/state/queries/preferences' import {type Gif} from '#/state/queries/tenor' import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate' import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate' @@ -104,6 +106,8 @@ export type ComposerState = { } export type ComposerAction = + | {type: 'init'; initialState: InitialState} + | {type: 'clear'} | {type: 'update_postgate'; postgate: AppBskyFeedPostgate.Record} | {type: 'update_threadgate'; threadgate: ThreadgateAllowUISetting[]} | { @@ -125,11 +129,63 @@ export type ComposerAction = export const MAX_IMAGES = 4 -export function composerReducer( +const EMPTY_STATE: ComposerState = { + thread: { + posts: [], + threadgate: [], + postgate: createPostgateRecord({post: ''}), + }, + activePostIndex: 0, + mutableNeedsFocusActive: false, +} + +/** + * Handles the internal state of the composer + */ +export function useComposerReducer(composerOpts: ComposerOpts | undefined) { + const {data: preferences} = usePreferencesQuery() + + const [state, dispatch] = useReducer(composerReducer, EMPTY_STATE) + + const open = !!composerOpts + const [prevOpen, setPrevOpen] = useState(open) + if (open !== prevOpen) { + setPrevOpen(open) + if (open) { + dispatch({ + type: 'init', + initialState: { + initImageUris: composerOpts.imageUris, + initQuoteUri: composerOpts.quote?.uri, + initText: composerOpts.text, + initMention: composerOpts.mention, + initInteractionSettings: preferences?.postInteractionSettings, + }, + }) + } else { + dispatch({type: 'clear'}) + } + } + + const isDirty = state.thread.posts.some( + post => + post.shortenedGraphemeLength > 0 || post.embed.media || post.embed.link, + ) + + return [state, dispatch, isDirty] as const +} + +function composerReducer( state: ComposerState, action: ComposerAction, ): ComposerState { switch (action.type) { + case 'init': { + return createComposerState(action.initialState) + } + case 'clear': { + return state + } case 'update_postgate': { return { ...state, @@ -482,13 +538,7 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft { } } -export function createComposerState({ - initText, - initMention, - initImageUris, - initQuoteUri, - initInteractionSettings, -}: { +type InitialState = { initText: string | undefined initMention: string | undefined initImageUris: ComposerOpts['imageUris'] @@ -496,7 +546,15 @@ export function createComposerState({ initInteractionSettings: | BskyPreferences['postInteractionSettings'] | undefined -}): ComposerState { +} + +function createComposerState({ + initText, + initMention, + initImageUris, + initQuoteUri, + initInteractionSettings, +}: InitialState): ComposerState { let media: ImagesMedia | undefined if (initImageUris?.length) { media = { diff --git a/src/view/shell/Composer.ios.tsx b/src/view/shell/Composer.ios.tsx index 8def80f91c..6cc2255cfc 100644 --- a/src/view/shell/Composer.ios.tsx +++ b/src/view/shell/Composer.ios.tsx @@ -1,34 +1,31 @@ -import {useEffect, useRef, useState} from 'react' +import {useEffect} from 'react' import {Modal, View} from 'react-native' +import {SystemBars} from 'react-native-edge-to-edge' -import {useDialogStateControlContext} from '#/state/dialogs' import {useComposerState} from '#/state/shell/composer' +import {useComposerReducer} from '#/view/com/composer/state/composer' import {atoms as a, useTheme} from '#/alf' import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' export function Composer({}: {winHeight: number}) { - const {setFullyExpandedCount} = useDialogStateControlContext() const t = useTheme() const state = useComposerState() const ref = useComposerCancelRef() - const [isDirty, setIsDirty] = useState( - !!state?.text || - !!state?.imageUris || - !!state?.videoUri || - !!state?.mention, - ) const open = !!state - const prevOpen = useRef(open) + + const [composerState, composerDispatch, isDirty] = useComposerReducer(state) useEffect(() => { - if (open && !prevOpen.current) { - setFullyExpandedCount(c => c + 1) - } else if (!open && prevOpen.current) { - setFullyExpandedCount(c => c - 1) + if (open) { + const entry = SystemBars.pushStackEntry({ + style: {statusBar: 'light'}, + }) + return () => { + SystemBars.popStackEntry(entry) + } } - prevOpen.current = open - }, [open, setFullyExpandedCount]) + }, [open]) return ( diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx index a17de6163d..42a5452cb1 100644 --- a/src/view/shell/Composer.tsx +++ b/src/view/shell/Composer.tsx @@ -1,18 +1,35 @@ import {useEffect} from 'react' import {Animated, Easing} from 'react-native' +import {SystemBars} from 'react-native-edge-to-edge' import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue' import {useComposerState} from '#/state/shell/composer' import {atoms as a, useTheme} from '#/alf' import {ComposePost} from '../com/composer/Composer' +import {useComposerReducer} from '../com/composer/state/composer' export function Composer({winHeight}: {winHeight: number}) { const state = useComposerState() const t = useTheme() const initInterp = useAnimatedValue(0) + const open = !!state + + const [composerState, composerDispatch, isDirty] = useComposerReducer(state) + useEffect(() => { - if (state) { + if (open) { + const entry = SystemBars.pushStackEntry({ + style: {statusBar: t.scheme === 'light' ? 'dark' : 'light'}, + }) + return () => { + SystemBars.popStackEntry(entry) + } + } + }, [open, t.scheme]) + + useEffect(() => { + if (open) { Animated.timing(initInterp, { toValue: 1, duration: 300, @@ -22,7 +39,7 @@ export function Composer({winHeight}: {winHeight: number}) { } else { initInterp.setValue(0) } - }, [initInterp, state]) + }, [initInterp, open]) const wrapperAnimStyle = { transform: [ { @@ -37,7 +54,7 @@ export function Composer({winHeight}: {winHeight: number}) { // rendering // = - if (!state) { + if (!open) { return null } @@ -55,6 +72,9 @@ export function Composer({winHeight}: {winHeight: number}) { text={state.text} imageUris={state.imageUris} videoUri={state.videoUri} + composerState={composerState} + composerDispatch={composerDispatch} + isDirty={isDirty} /> ) diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index a27e891680..d31c66c70d 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -4,15 +4,19 @@ import {DismissableLayer, FocusGuards, FocusScope} from 'radix-ui/internal' import {RemoveScrollBar} from 'react-remove-scroll-bar' import {useA11y} from '#/state/a11y' -import {useModals} from '#/state/modals' import {type ComposerOpts, useComposerState} from '#/state/shell/composer' +import {ComposePost, useComposerCancelRef} from '#/view/com/composer/Composer' +import { + type ComposerAction, + type ComposerState, + useComposerReducer, +} from '#/view/com/composer/state/composer' import { EmojiPicker, type EmojiPickerPosition, type EmojiPickerState, } from '#/view/com/composer/text-input/web/EmojiPicker' import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf' -import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' const BOTTOM_BAR_HEIGHT = 61 @@ -20,6 +24,8 @@ export function Composer({}: {winHeight: number}) { const state = useComposerState() const isActive = !!state + const [composerState, composerDispatch, isDirty] = useComposerReducer(state) + // rendering // = @@ -30,14 +36,28 @@ export function Composer({}: {winHeight: number}) { return ( <> - + ) } -function Inner({state}: {state: ComposerOpts}) { +function Inner({ + state, + composerState, + composerDispatch, + isDirty, +}: { + state: ComposerOpts + composerState: ComposerState + composerDispatch: React.Dispatch + isDirty: boolean +}) { const ref = useComposerCancelRef() - const {isModalActive} = useModals() const t = useTheme() const {gtMobile} = useBreakpoints() const {reduceMotionEnabled} = useA11y() @@ -82,12 +102,7 @@ function Inner({state}: {state: ComposerOpts}) { ])} onFocusOutside={evt => evt.preventDefault()} onInteractOutside={evt => evt.preventDefault()} - onDismiss={() => { - // TEMP: remove when all modals are ALF'd -sfn - if (!isModalActive) { - ref.current?.onPressCancel() - } - }}> + onDismiss={() => ref.current?.onPressCancel()}>