hoist composer state
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user