hoist composer state

This commit is contained in:
Samuel Newman
2025-10-31 10:39:59 +02:00
parent 474f0c1ffe
commit a6c5471294
5 changed files with 156 additions and 96 deletions
+21 -56
View File
@@ -1,9 +1,10 @@
import React, { import {
Fragment,
memo,
useCallback, useCallback,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useMemo, useMemo,
useReducer,
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
@@ -84,7 +85,6 @@ import {
createComposerImage, createComposerImage,
pasteImage, pasteImage,
} from '#/state/gallery' } from '#/state/gallery'
import {useModalControls} from '#/state/modals'
import {useRequireAltTextEnabled} from '#/state/preferences' import {useRequireAltTextEnabled} from '#/state/preferences'
import { import {
fromPostLanguages, fromPostLanguages,
@@ -92,7 +92,6 @@ import {
useLanguagePrefs, useLanguagePrefs,
useLanguagePrefsApi, useLanguagePrefsApi,
} from '#/state/preferences/languages' } from '#/state/preferences/languages'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {type Gif} from '#/state/queries/tenor' import {type Gif} from '#/state/queries/tenor'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
@@ -139,8 +138,7 @@ import {
} from './SelectMediaButton' } from './SelectMediaButton'
import { import {
type ComposerAction, type ComposerAction,
composerReducer, type ComposerState,
createComposerState,
type EmbedDraft, type EmbedDraft,
MAX_IMAGES, MAX_IMAGES,
type PostAction, type PostAction,
@@ -167,16 +165,17 @@ export const ComposePost = ({
onPost, onPost,
onPostSuccess, onPostSuccess,
quote: initQuote, quote: initQuote,
mention: initMention,
openEmojiPicker, openEmojiPicker,
text: initText,
imageUris: initImageUris,
videoUri: initVideoUri, videoUri: initVideoUri,
composerState,
composerDispatch,
cancelRef, cancelRef,
setIsDirty, isDirty,
}: Props & { }: Props & {
composerState: ComposerState
composerDispatch: React.Dispatch<ComposerAction>
cancelRef?: React.RefObject<CancelRef | null> cancelRef?: React.RefObject<CancelRef | null>
setIsDirty?: React.Dispatch<React.SetStateAction<boolean>> isDirty: boolean
}) => { }) => {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
@@ -190,8 +189,6 @@ export const ComposePost = ({
const textInput = useRef<TextInputRef>(null) const textInput = useRef<TextInputRef>(null)
const discardPromptControl = Prompt.usePromptControl() const discardPromptControl = Prompt.usePromptControl()
const {closeAllDialogs} = useDialogStateControlContext() const {closeAllDialogs} = useDialogStateControlContext()
const {closeAllModals} = useModalControls()
const {data: preferences} = usePreferencesQuery()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
@@ -237,18 +234,6 @@ export const ComposePost = ({
setReplyToLanguages([]) setReplyToLanguages([])
} }
const [composerState, composerDispatch] = useReducer(
composerReducer,
{
initImageUris,
initQuoteUri: initQuote?.uri,
initText,
initMention,
initInteractionSettings: preferences?.postInteractionSettings,
},
createComposerState,
)
const thread = composerState.thread const thread = composerState.thread
const activePost = thread.posts[composerState.activePostIndex] const activePost = thread.posts[composerState.activePostIndex]
const nextPost: PostDraft | undefined = const nextPost: PostDraft | undefined =
@@ -261,10 +246,10 @@ export const ComposePost = ({
postAction, postAction,
}) })
}, },
[activePost.id], [activePost.id, composerDispatch],
) )
const selectVideo = React.useCallback( const selectVideo = useCallback(
(postId: string, asset: ImagePickerAsset) => { (postId: string, asset: ImagePickerAsset) => {
const abortController = new AbortController() const abortController = new AbortController()
composerDispatch({ composerDispatch({
@@ -307,7 +292,7 @@ export const ComposePost = ({
onInitVideo() onInitVideo()
}, [onInitVideo]) }, [onInitVideo])
const clearVideo = React.useCallback( const clearVideo = useCallback(
(postId: string) => { (postId: string) => {
composerDispatch({ composerDispatch({
type: 'update_post', type: 'update_post',
@@ -344,26 +329,7 @@ export const ComposePost = ({
[insets, isKeyboardVisible], [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(() => { const onPressCancel = useNonReactiveCallback(() => {
// web only, so it's fine w.r.t. the Modal
const didCloseAutocomplete = textInput.current?.maybeClosePopup() const didCloseAutocomplete = textInput.current?.maybeClosePopup()
if (isWeb && didCloseAutocomplete) { if (isWeb && didCloseAutocomplete) {
return return
@@ -386,7 +352,7 @@ export const ComposePost = ({
const backHandler = BackHandler.addEventListener( const backHandler = BackHandler.addEventListener(
'hardwareBackPress', 'hardwareBackPress',
() => { () => {
if (closeAllDialogs() || closeAllModals()) { if (closeAllDialogs()) {
return true return true
} }
onPressCancel() onPressCancel()
@@ -396,7 +362,7 @@ export const ComposePost = ({
return () => { return () => {
backHandler.remove() backHandler.remove()
} }
}, [onPressCancel, closeAllDialogs, closeAllModals]) }, [onPressCancel, closeAllDialogs])
const missingAltError = useMemo(() => { const missingAltError = useMemo(() => {
if (!requireAltTextEnabled) { if (!requireAltTextEnabled) {
@@ -434,7 +400,7 @@ export const ComposePost = ({
), ),
) )
const onPressPublish = React.useCallback(async () => { const onPressPublish = useCallback(async () => {
if (isPublishing) { if (isPublishing) {
return return
} }
@@ -629,7 +595,7 @@ export const ComposePost = ({
onPressPublish() onPressPublish()
}) })
React.useEffect(() => { useEffect(() => {
if (publishOnUpload) { if (publishOnUpload) {
let erroredVideos = 0 let erroredVideos = 0
let uploadingVideos = 0 let uploadingVideos = 0
@@ -673,8 +639,7 @@ export const ComposePost = ({
if (rect) { if (rect) {
openEmojiPicker?.({ openEmojiPicker?.({
...rect, ...rect,
nextFocusRef: nextFocusRef: textInput as unknown as React.RefObject<HTMLElement>,
textInput as unknown as React.MutableRefObject<HTMLElement>,
}) })
} }
}, [openEmojiPicker]) }, [openEmojiPicker])
@@ -786,7 +751,7 @@ export const ComposePost = ({
onLayout={onScrollViewLayout}> onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined} {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
{thread.posts.map((post, index) => ( {thread.posts.map((post, index) => (
<React.Fragment key={post.id}> <Fragment key={post.id}>
<ComposerPost <ComposerPost
post={post} post={post}
dispatch={composerDispatch} dispatch={composerDispatch}
@@ -806,7 +771,7 @@ export const ComposePost = ({
{isWebFooterSticky && post.id === activePost.id && ( {isWebFooterSticky && post.id === activePost.id && (
<View style={styles.stickyFooterWeb}>{footer}</View> <View style={styles.stickyFooterWeb}>{footer}</View>
)} )}
</React.Fragment> </Fragment>
))} ))}
</Animated.ScrollView> </Animated.ScrollView>
{!isWebFooterSticky && footer} {!isWebFooterSticky && footer}
@@ -825,7 +790,7 @@ export const ComposePost = ({
) )
} }
let ComposerPost = React.memo(function ComposerPost({ let ComposerPost = memo(function ComposerPost({
post, post,
dispatch, dispatch,
textInput, textInput,
+67 -9
View File
@@ -1,3 +1,4 @@
import {useReducer, useState} from 'react'
import {type ImagePickerAsset} from 'expo-image-picker' import {type ImagePickerAsset} from 'expo-image-picker'
import { import {
type AppBskyFeedPostgate, type AppBskyFeedPostgate,
@@ -17,6 +18,7 @@ import {
} from '#/lib/strings/url-helpers' } from '#/lib/strings/url-helpers'
import {type ComposerImage, createInitialImages} from '#/state/gallery' import {type ComposerImage, createInitialImages} from '#/state/gallery'
import {createPostgateRecord} from '#/state/queries/postgate/util' import {createPostgateRecord} from '#/state/queries/postgate/util'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {type Gif} from '#/state/queries/tenor' import {type Gif} from '#/state/queries/tenor'
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate' import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate'
@@ -104,6 +106,8 @@ export type ComposerState = {
} }
export type ComposerAction = export type ComposerAction =
| {type: 'init'; initialState: InitialState}
| {type: 'clear'}
| {type: 'update_postgate'; postgate: AppBskyFeedPostgate.Record} | {type: 'update_postgate'; postgate: AppBskyFeedPostgate.Record}
| {type: 'update_threadgate'; threadgate: ThreadgateAllowUISetting[]} | {type: 'update_threadgate'; threadgate: ThreadgateAllowUISetting[]}
| { | {
@@ -125,11 +129,63 @@ export type ComposerAction =
export const MAX_IMAGES = 4 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, state: ComposerState,
action: ComposerAction, action: ComposerAction,
): ComposerState { ): ComposerState {
switch (action.type) { switch (action.type) {
case 'init': {
return createComposerState(action.initialState)
}
case 'clear': {
return state
}
case 'update_postgate': { case 'update_postgate': {
return { return {
...state, ...state,
@@ -482,13 +538,7 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
} }
} }
export function createComposerState({ type InitialState = {
initText,
initMention,
initImageUris,
initQuoteUri,
initInteractionSettings,
}: {
initText: string | undefined initText: string | undefined
initMention: string | undefined initMention: string | undefined
initImageUris: ComposerOpts['imageUris'] initImageUris: ComposerOpts['imageUris']
@@ -496,7 +546,15 @@ export function createComposerState({
initInteractionSettings: initInteractionSettings:
| BskyPreferences['postInteractionSettings'] | BskyPreferences['postInteractionSettings']
| undefined | undefined
}): ComposerState { }
function createComposerState({
initText,
initMention,
initImageUris,
initQuoteUri,
initInteractionSettings,
}: InitialState): ComposerState {
let media: ImagesMedia | undefined let media: ImagesMedia | undefined
if (initImageUris?.length) { if (initImageUris?.length) {
media = { media = {
+16 -17
View File
@@ -1,34 +1,31 @@
import {useEffect, useRef, useState} from 'react' import {useEffect} from 'react'
import {Modal, View} from 'react-native' 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 {useComposerState} from '#/state/shell/composer'
import {useComposerReducer} from '#/view/com/composer/state/composer'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' import {ComposePost, useComposerCancelRef} from '../com/composer/Composer'
export function Composer({}: {winHeight: number}) { export function Composer({}: {winHeight: number}) {
const {setFullyExpandedCount} = useDialogStateControlContext()
const t = useTheme() const t = useTheme()
const state = useComposerState() const state = useComposerState()
const ref = useComposerCancelRef() const ref = useComposerCancelRef()
const [isDirty, setIsDirty] = useState(
!!state?.text ||
!!state?.imageUris ||
!!state?.videoUri ||
!!state?.mention,
)
const open = !!state const open = !!state
const prevOpen = useRef(open)
const [composerState, composerDispatch, isDirty] = useComposerReducer(state)
useEffect(() => { useEffect(() => {
if (open && !prevOpen.current) { if (open) {
setFullyExpandedCount(c => c + 1) const entry = SystemBars.pushStackEntry({
} else if (!open && prevOpen.current) { style: {statusBar: 'light'},
setFullyExpandedCount(c => c - 1) })
return () => {
SystemBars.popStackEntry(entry)
}
} }
prevOpen.current = open }, [open])
}, [open, setFullyExpandedCount])
return ( return (
<Modal <Modal
@@ -50,7 +47,9 @@ export function Composer({}: {winHeight: number}) {
text={state?.text} text={state?.text}
imageUris={state?.imageUris} imageUris={state?.imageUris}
videoUri={state?.videoUri} videoUri={state?.videoUri}
setIsDirty={setIsDirty} composerState={composerState}
composerDispatch={composerDispatch}
isDirty={isDirty}
/> />
</View> </View>
</Modal> </Modal>
+23 -3
View File
@@ -1,18 +1,35 @@
import {useEffect} from 'react' import {useEffect} from 'react'
import {Animated, Easing} from 'react-native' import {Animated, Easing} from 'react-native'
import {SystemBars} from 'react-native-edge-to-edge'
import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue' import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue'
import {useComposerState} from '#/state/shell/composer' import {useComposerState} from '#/state/shell/composer'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {ComposePost} from '../com/composer/Composer' import {ComposePost} from '../com/composer/Composer'
import {useComposerReducer} from '../com/composer/state/composer'
export function Composer({winHeight}: {winHeight: number}) { export function Composer({winHeight}: {winHeight: number}) {
const state = useComposerState() const state = useComposerState()
const t = useTheme() const t = useTheme()
const initInterp = useAnimatedValue(0) const initInterp = useAnimatedValue(0)
const open = !!state
const [composerState, composerDispatch, isDirty] = useComposerReducer(state)
useEffect(() => { 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, { Animated.timing(initInterp, {
toValue: 1, toValue: 1,
duration: 300, duration: 300,
@@ -22,7 +39,7 @@ export function Composer({winHeight}: {winHeight: number}) {
} else { } else {
initInterp.setValue(0) initInterp.setValue(0)
} }
}, [initInterp, state]) }, [initInterp, open])
const wrapperAnimStyle = { const wrapperAnimStyle = {
transform: [ transform: [
{ {
@@ -37,7 +54,7 @@ export function Composer({winHeight}: {winHeight: number}) {
// rendering // rendering
// = // =
if (!state) { if (!open) {
return null return null
} }
@@ -55,6 +72,9 @@ export function Composer({winHeight}: {winHeight: number}) {
text={state.text} text={state.text}
imageUris={state.imageUris} imageUris={state.imageUris}
videoUri={state.videoUri} videoUri={state.videoUri}
composerState={composerState}
composerDispatch={composerDispatch}
isDirty={isDirty}
/> />
</Animated.View> </Animated.View>
) )
+29 -11
View File
@@ -4,15 +4,19 @@ import {DismissableLayer, FocusGuards, FocusScope} from 'radix-ui/internal'
import {RemoveScrollBar} from 'react-remove-scroll-bar' import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {useA11y} from '#/state/a11y' import {useA11y} from '#/state/a11y'
import {useModals} from '#/state/modals'
import {type ComposerOpts, useComposerState} from '#/state/shell/composer' 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 { import {
EmojiPicker, EmojiPicker,
type EmojiPickerPosition, type EmojiPickerPosition,
type EmojiPickerState, type EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker' } from '#/view/com/composer/text-input/web/EmojiPicker'
import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf' import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf'
import {ComposePost, useComposerCancelRef} from '../com/composer/Composer'
const BOTTOM_BAR_HEIGHT = 61 const BOTTOM_BAR_HEIGHT = 61
@@ -20,6 +24,8 @@ export function Composer({}: {winHeight: number}) {
const state = useComposerState() const state = useComposerState()
const isActive = !!state const isActive = !!state
const [composerState, composerDispatch, isDirty] = useComposerReducer(state)
// rendering // rendering
// = // =
@@ -30,14 +36,28 @@ export function Composer({}: {winHeight: number}) {
return ( return (
<> <>
<RemoveScrollBar /> <RemoveScrollBar />
<Inner state={state} /> <Inner
state={state}
composerState={composerState}
composerDispatch={composerDispatch}
isDirty={isDirty}
/>
</> </>
) )
} }
function Inner({state}: {state: ComposerOpts}) { function Inner({
state,
composerState,
composerDispatch,
isDirty,
}: {
state: ComposerOpts
composerState: ComposerState
composerDispatch: React.Dispatch<ComposerAction>
isDirty: boolean
}) {
const ref = useComposerCancelRef() const ref = useComposerCancelRef()
const {isModalActive} = useModals()
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {reduceMotionEnabled} = useA11y() const {reduceMotionEnabled} = useA11y()
@@ -82,12 +102,7 @@ function Inner({state}: {state: ComposerOpts}) {
])} ])}
onFocusOutside={evt => evt.preventDefault()} onFocusOutside={evt => evt.preventDefault()}
onInteractOutside={evt => evt.preventDefault()} onInteractOutside={evt => evt.preventDefault()}
onDismiss={() => { onDismiss={() => ref.current?.onPressCancel()}>
// TEMP: remove when all modals are ALF'd -sfn
if (!isModalActive) {
ref.current?.onPressCancel()
}
}}>
<View <View
style={[ style={[
styles.container, styles.container,
@@ -110,6 +125,9 @@ function Inner({state}: {state: ComposerOpts}) {
openEmojiPicker={onOpenPicker} openEmojiPicker={onOpenPicker}
text={state.text} text={state.text}
imageUris={state.imageUris} imageUris={state.imageUris}
composerState={composerState}
composerDispatch={composerDispatch}
isDirty={isDirty}
/> />
</View> </View>
<EmojiPicker state={pickerState} close={onClosePicker} /> <EmojiPicker state={pickerState} close={onClosePicker} />