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,
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<ComposerAction>
cancelRef?: React.RefObject<CancelRef | null>
setIsDirty?: React.Dispatch<React.SetStateAction<boolean>>
isDirty: boolean
}) => {
const {currentAccount} = useSession()
const agent = useAgent()
@@ -190,8 +189,6 @@ export const ComposePost = ({
const textInput = useRef<TextInputRef>(null)
const discardPromptControl = Prompt.usePromptControl()
const {closeAllDialogs} = useDialogStateControlContext()
const {closeAllModals} = useModalControls()
const {data: preferences} = usePreferencesQuery()
const navigation = useNavigation<NavigationProp>()
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<HTMLElement>,
nextFocusRef: textInput as unknown as React.RefObject<HTMLElement>,
})
}
}, [openEmojiPicker])
@@ -786,7 +751,7 @@ export const ComposePost = ({
onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
{thread.posts.map((post, index) => (
<React.Fragment key={post.id}>
<Fragment key={post.id}>
<ComposerPost
post={post}
dispatch={composerDispatch}
@@ -806,7 +771,7 @@ export const ComposePost = ({
{isWebFooterSticky && post.id === activePost.id && (
<View style={styles.stickyFooterWeb}>{footer}</View>
)}
</React.Fragment>
</Fragment>
))}
</Animated.ScrollView>
{!isWebFooterSticky && footer}
@@ -825,7 +790,7 @@ export const ComposePost = ({
)
}
let ComposerPost = React.memo(function ComposerPost({
let ComposerPost = memo(function ComposerPost({
post,
dispatch,
textInput,
+67 -9
View File
@@ -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 = {
+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 {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 (
<Modal
@@ -50,7 +47,9 @@ export function Composer({}: {winHeight: number}) {
text={state?.text}
imageUris={state?.imageUris}
videoUri={state?.videoUri}
setIsDirty={setIsDirty}
composerState={composerState}
composerDispatch={composerDispatch}
isDirty={isDirty}
/>
</View>
</Modal>
+23 -3
View File
@@ -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}
/>
</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 {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 (
<>
<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 {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()}>
<View
style={[
styles.container,
@@ -110,6 +125,9 @@ function Inner({state}: {state: ComposerOpts}) {
openEmojiPicker={onOpenPicker}
text={state.text}
imageUris={state.imageUris}
composerState={composerState}
composerDispatch={composerDispatch}
isDirty={isDirty}
/>
</View>
<EmojiPicker state={pickerState} close={onClosePicker} />