Bluesky app fork with some witchin' additions 💫

Revert to old modal on android (#4458)

* revert to old modal on android

* close alf dialogs before closing composer

* Try to fix white area

* Use hook

* Fix Back button

* oops

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>

authored by samuel.fm

Dan Abramov and committed by
GitHub
d85c8a09 14cddb7e

+173 -147
+4 -2
src/state/modals/index.tsx
··· 169 169 const ModalControlContext = React.createContext<{ 170 170 openModal: (modal: Modal) => void 171 171 closeModal: () => boolean 172 - closeAllModals: () => void 172 + closeAllModals: () => boolean 173 173 }>({ 174 174 openModal: () => {}, 175 175 closeModal: () => false, 176 - closeAllModals: () => {}, 176 + closeAllModals: () => false, 177 177 }) 178 178 179 179 /** ··· 206 206 }) 207 207 208 208 const closeAllModals = useNonReactiveCallback(() => { 209 + let wasActive = activeModals.length > 0 209 210 setActiveModals([]) 211 + return wasActive 210 212 }) 211 213 212 214 unstable__openModal = openModal
+4 -3
src/state/util.ts
··· 1 1 import {useCallback} from 'react' 2 + 3 + import {useDialogStateControlContext} from '#/state/dialogs' 2 4 import {useLightboxControls} from './lightbox' 3 5 import {useModalControls} from './modals' 4 6 import {useComposerControls} from './shell/composer' 5 7 import {useSetDrawerOpen} from './shell/drawer-open' 6 - import {useDialogStateControlContext} from '#/state/dialogs' 7 8 8 9 /** 9 10 * returns true if something was closed ··· 22 23 if (closeModal()) { 23 24 return true 24 25 } 25 - if (closeComposer()) { 26 + if (closeAllDialogs()) { 26 27 return true 27 28 } 28 - if (closeAllDialogs()) { 29 + if (closeComposer()) { 29 30 return true 30 31 } 31 32 setDrawerOpen(false)
+27 -40
src/view/com/composer/Composer.tsx
··· 8 8 } from 'react' 9 9 import { 10 10 ActivityIndicator, 11 + BackHandler, 11 12 Keyboard, 12 13 LayoutChangeEvent, 13 14 StyleSheet, ··· 17 18 import { 18 19 KeyboardAvoidingView, 19 20 KeyboardStickyView, 20 - useKeyboardContext, 21 + useKeyboardController, 21 22 } from 'react-native-keyboard-controller' 22 23 import Animated, { 23 24 interpolateColor, ··· 42 43 import {logEvent} from '#/lib/statsig/statsig' 43 44 import {logger} from '#/logger' 44 45 import {emitPostCreated} from '#/state/events' 46 + import {useModalControls} from '#/state/modals' 45 47 import {useModals} from '#/state/modals' 46 48 import {useRequireAltTextEnabled} from '#/state/preferences' 47 49 import { ··· 108 110 text: initText, 109 111 imageUris: initImageUris, 110 112 cancelRef, 111 - isModalReady, 112 113 }: Props & { 113 - isModalReady: boolean 114 114 cancelRef?: React.RefObject<CancelRef> 115 115 }) { 116 116 const {currentAccount} = useSession() ··· 128 128 const textInput = useRef<TextInputRef>(null) 129 129 const discardPromptControl = Prompt.usePromptControl() 130 130 const {closeAllDialogs} = useDialogStateControlContext() 131 + const {closeAllModals} = useModalControls() 131 132 const t = useTheme() 132 133 133 134 // Disable this in the composer to prevent any extra keyboard height being applied. 134 135 // See https://github.com/bluesky-social/social-app/pull/4399 135 - const {setEnabled} = useKeyboardContext() 136 + const {setEnabled} = useKeyboardController() 136 137 React.useEffect(() => { 137 138 if (!isAndroid) return 138 139 setEnabled(false) ··· 180 181 const insets = useSafeAreaInsets() 181 182 const viewStyles = useMemo( 182 183 () => ({ 184 + paddingTop: isAndroid ? insets.top : 0, 183 185 paddingBottom: 184 186 isAndroid || (isIOS && !isKeyboardVisible) ? insets.bottom : 0, 185 187 }), ··· 205 207 206 208 useImperativeHandle(cancelRef, () => ({onPressCancel})) 207 209 210 + // On Android, pressing Back should ask confirmation. 211 + useEffect(() => { 212 + if (!isAndroid) { 213 + return 214 + } 215 + const backHandler = BackHandler.addEventListener( 216 + 'hardwareBackPress', 217 + () => { 218 + if (closeAllDialogs() || closeAllModals()) { 219 + return true 220 + } 221 + onPressCancel() 222 + return true 223 + }, 224 + ) 225 + return () => { 226 + backHandler.remove() 227 + } 228 + }, [onPressCancel, closeAllDialogs, closeAllModals]) 229 + 208 230 // listen to escape key on desktop web 209 231 const onEscape = useCallback( 210 232 (e: KeyboardEvent) => { ··· 408 430 bottomBarAnimatedStyle, 409 431 } = useAnimatedBorders() 410 432 411 - // Backup focus on android, if the keyboard *still* refuses to show 412 - useEffect(() => { 413 - if (!isAndroid) return 414 - if (!isModalReady) return 415 - 416 - function tryFocus() { 417 - if (!Keyboard.isVisible()) { 418 - textInput.current?.blur() 419 - textInput.current?.focus() 420 - } 421 - } 422 - 423 - tryFocus() 424 - // Retry with enough gap to avoid interrupting the previous attempt. 425 - // Unfortunately we don't know which attempt will succeed. 426 - const retryInterval = setInterval(tryFocus, 500) 427 - 428 - function stopTrying() { 429 - clearInterval(retryInterval) 430 - } 431 - 432 - // Deactivate this fallback as soon as anything happens. 433 - const sub1 = Keyboard.addListener('keyboardDidShow', stopTrying) 434 - const sub2 = Keyboard.addListener('keyboardDidHide', stopTrying) 435 - return () => { 436 - clearInterval(retryInterval) 437 - sub1.remove() 438 - sub2.remove() 439 - } 440 - }, [isModalReady]) 441 - 442 433 return ( 443 434 <> 444 435 <KeyboardAvoidingView ··· 567 558 ref={textInput} 568 559 richtext={richtext} 569 560 placeholder={selectTextInputPlaceholder} 570 - // fixes autofocus on android 571 - key={ 572 - isAndroid ? (isModalReady ? 'ready' : 'animating') : 'static' 573 - } 574 - autoFocus={isAndroid ? isModalReady : true} 561 + autoFocus 575 562 setRichText={setRichText} 576 563 onPhotoPasted={onPhotoPasted} 577 564 onPressPublish={onPressPublish}
+80
src/view/shell/Composer.ios.tsx
··· 1 + import React, {useLayoutEffect} from 'react' 2 + import {Modal, View} from 'react-native' 3 + import {StatusBar} from 'expo-status-bar' 4 + import * as SystemUI from 'expo-system-ui' 5 + import {observer} from 'mobx-react-lite' 6 + 7 + import {useComposerState} from '#/state/shell/composer' 8 + import {atoms as a, useTheme} from '#/alf' 9 + import {getBackgroundColor, useThemeName} from '#/alf/util/useColorModeTheme' 10 + import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' 11 + 12 + export const Composer = observer(function ComposerImpl({}: { 13 + winHeight: number 14 + }) { 15 + const t = useTheme() 16 + const state = useComposerState() 17 + const ref = useComposerCancelRef() 18 + 19 + const open = !!state 20 + 21 + return ( 22 + <Modal 23 + aria-modal 24 + accessibilityViewIsModal 25 + visible={open} 26 + presentationStyle="pageSheet" 27 + animationType="slide" 28 + onRequestClose={() => ref.current?.onPressCancel()}> 29 + <View style={[t.atoms.bg, a.flex_1]}> 30 + <Providers open={open}> 31 + <ComposePost 32 + cancelRef={ref} 33 + replyTo={state?.replyTo} 34 + onPost={state?.onPost} 35 + quote={state?.quote} 36 + mention={state?.mention} 37 + text={state?.text} 38 + imageUris={state?.imageUris} 39 + /> 40 + </Providers> 41 + </View> 42 + </Modal> 43 + ) 44 + }) 45 + 46 + function Providers({ 47 + children, 48 + open, 49 + }: { 50 + children: React.ReactNode 51 + open: boolean 52 + }) { 53 + // on iOS, it's a native formSheet. We use FullWindowOverlay to make 54 + // the dialogs appear over it 55 + return ( 56 + <> 57 + {children} 58 + <IOSModalBackground active={open} /> 59 + </> 60 + ) 61 + } 62 + 63 + // Generally, the backdrop of the app is the theme color, but when this is open 64 + // we want it to be black due to the modal being a form sheet. 65 + function IOSModalBackground({active}: {active: boolean}) { 66 + const theme = useThemeName() 67 + 68 + useLayoutEffect(() => { 69 + SystemUI.setBackgroundColorAsync('black') 70 + 71 + return () => { 72 + SystemUI.setBackgroundColorAsync(getBackgroundColor(theme)) 73 + } 74 + }, [theme]) 75 + 76 + // Set the status bar to light - however, only if the modal is active 77 + // If we rely on this component being mounted to set this, 78 + // there'll be a delay before it switches back to default. 79 + return active ? <StatusBar style="light" animated /> : null 80 + }
+58 -101
src/view/shell/Composer.tsx
··· 1 - import React, {useLayoutEffect, useState} from 'react' 2 - import {Modal, View} from 'react-native' 3 - import {GestureHandlerRootView} from 'react-native-gesture-handler' 4 - import {RootSiblingParent} from 'react-native-root-siblings' 5 - import {StatusBar} from 'expo-status-bar' 6 - import * as SystemUI from 'expo-system-ui' 1 + import React, {useEffect} from 'react' 2 + import {Animated, Easing, StyleSheet, View} from 'react-native' 7 3 import {observer} from 'mobx-react-lite' 8 4 9 - import {isIOS} from '#/platform/detection' 10 - import {Provider as LegacyModalProvider} from '#/state/modals' 11 - import {useComposerState} from '#/state/shell/composer' 12 - import {ModalsContainer as LegacyModalsContainer} from '#/view/com/modals/Modal' 13 - import {atoms as a, useTheme} from '#/alf' 14 - import {getBackgroundColor, useThemeName} from '#/alf/util/useColorModeTheme' 15 - import { 16 - Outlet as PortalOutlet, 17 - Provider as PortalProvider, 18 - } from '#/components/Portal' 19 - import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' 5 + import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' 6 + import {usePalette} from 'lib/hooks/usePalette' 7 + import {useComposerState} from 'state/shell/composer' 8 + import {ComposePost} from '../com/composer/Composer' 20 9 21 - export const Composer = observer(function ComposerImpl({}: { 10 + export const Composer = observer(function ComposerImpl({ 11 + winHeight, 12 + }: { 22 13 winHeight: number 23 14 }) { 24 - const t = useTheme() 25 15 const state = useComposerState() 26 - const ref = useComposerCancelRef() 27 - const [isModalReady, setIsModalReady] = useState(false) 16 + const pal = usePalette('default') 17 + const initInterp = useAnimatedValue(0) 28 18 29 - const open = !!state 30 - const [prevOpen, setPrevOpen] = useState(open) 31 - if (open !== prevOpen) { 32 - setPrevOpen(open) 33 - if (!open) { 34 - setIsModalReady(false) 19 + useEffect(() => { 20 + if (state) { 21 + Animated.timing(initInterp, { 22 + toValue: 1, 23 + duration: 300, 24 + easing: Easing.out(Easing.exp), 25 + useNativeDriver: true, 26 + }).start() 27 + } else { 28 + initInterp.setValue(0) 35 29 } 30 + }, [initInterp, state]) 31 + const wrapperAnimStyle = { 32 + transform: [ 33 + { 34 + translateY: initInterp.interpolate({ 35 + inputRange: [0, 1], 36 + outputRange: [winHeight, 0], 37 + }), 38 + }, 39 + ], 40 + } 41 + 42 + // rendering 43 + // = 44 + 45 + if (!state) { 46 + return <View /> 36 47 } 37 48 38 49 return ( 39 - <Modal 50 + <Animated.View 51 + style={[styles.wrapper, pal.view, wrapperAnimStyle]} 40 52 aria-modal 41 - accessibilityViewIsModal 42 - visible={open} 43 - presentationStyle="formSheet" 44 - animationType="slide" 45 - onShow={() => setIsModalReady(true)} 46 - onRequestClose={() => ref.current?.onPressCancel()}> 47 - <View style={[t.atoms.bg, a.flex_1]}> 48 - <Providers open={open}> 49 - <ComposePost 50 - isModalReady={isModalReady} 51 - cancelRef={ref} 52 - replyTo={state?.replyTo} 53 - onPost={state?.onPost} 54 - quote={state?.quote} 55 - mention={state?.mention} 56 - text={state?.text} 57 - imageUris={state?.imageUris} 58 - /> 59 - </Providers> 60 - </View> 61 - </Modal> 53 + accessibilityViewIsModal> 54 + <ComposePost 55 + replyTo={state.replyTo} 56 + onPost={state.onPost} 57 + quote={state.quote} 58 + mention={state.mention} 59 + text={state.text} 60 + imageUris={state.imageUris} 61 + /> 62 + </Animated.View> 62 63 ) 63 64 }) 64 65 65 - function Providers({ 66 - children, 67 - open, 68 - }: { 69 - children: React.ReactNode 70 - open: boolean 71 - }) { 72 - // on iOS, it's a native formSheet. We use FullWindowOverlay to make 73 - // the dialogs appear over it 74 - if (isIOS) { 75 - return ( 76 - <> 77 - {children} 78 - <IOSModalBackground active={open} /> 79 - </> 80 - ) 81 - } else { 82 - // on Android we just nest the dialogs within it 83 - return ( 84 - <GestureHandlerRootView style={a.flex_1}> 85 - <RootSiblingParent> 86 - <LegacyModalProvider> 87 - <PortalProvider> 88 - {children} 89 - <LegacyModalsContainer /> 90 - <PortalOutlet /> 91 - </PortalProvider> 92 - </LegacyModalProvider> 93 - </RootSiblingParent> 94 - </GestureHandlerRootView> 95 - ) 96 - } 97 - } 98 - 99 - // Generally, the backdrop of the app is the theme color, but when this is open 100 - // we want it to be black due to the modal being a form sheet. 101 - function IOSModalBackground({active}: {active: boolean}) { 102 - const theme = useThemeName() 103 - 104 - useLayoutEffect(() => { 105 - SystemUI.setBackgroundColorAsync('black') 106 - 107 - return () => { 108 - SystemUI.setBackgroundColorAsync(getBackgroundColor(theme)) 109 - } 110 - }, [theme]) 111 - 112 - // Set the status bar to light - however, only if the modal is active 113 - // If we rely on this component being mounted to set this, 114 - // there'll be a delay before it switches back to default. 115 - return active ? <StatusBar style="light" animated /> : null 116 - } 66 + const styles = StyleSheet.create({ 67 + wrapper: { 68 + position: 'absolute', 69 + top: 0, 70 + bottom: 0, 71 + width: '100%', 72 + }, 73 + })
-1
src/view/shell/Composer.web.tsx
··· 56 56 t.atoms.border_contrast_medium, 57 57 ]}> 58 58 <ComposePost 59 - isModalReady={true} 60 59 replyTo={state.replyTo} 61 60 quote={state.quote} 62 61 onPost={state.onPost}