From cca344a3d1cdca3d4e63806a9bd5f7867f8961d4 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 9 Oct 2024 21:30:42 +0300 Subject: Allow nested sheets without boilerplate (#5660) Co-authored-by: Hailey --- modules/bottom-sheet/index.ts | 10 + modules/bottom-sheet/src/BottomSheet.tsx | 114 +---- .../src/BottomSheetNativeComponent.tsx | 103 ++++ modules/bottom-sheet/src/BottomSheetPortal.tsx | 40 ++ modules/bottom-sheet/src/lib/Portal.tsx | 67 +++ src/App.native.tsx | 19 +- src/components/Dialog/index.tsx | 31 +- src/components/dialogs/GifSelect.tsx | 4 - src/components/dialogs/MutedWords.tsx | 528 ++++++++++----------- .../dialogs/PostInteractionSettingsDialog.tsx | 9 +- src/state/dialogs/index.tsx | 4 +- src/view/com/composer/Composer.tsx | 20 +- src/view/com/composer/GifAltText.tsx | 9 +- src/view/com/composer/photos/Gallery.tsx | 13 +- .../com/composer/photos/ImageAltTextDialog.tsx | 6 +- src/view/com/composer/photos/SelectGifBtn.tsx | 5 +- src/view/com/composer/threadgate/ThreadgateBtn.tsx | 5 - src/view/com/composer/videos/SubtitleDialog.tsx | 4 +- src/view/screens/Storybook/Dialogs.tsx | 66 ++- src/view/shell/index.tsx | 2 + 20 files changed, 550 insertions(+), 509 deletions(-) create mode 100644 modules/bottom-sheet/src/BottomSheetNativeComponent.tsx create mode 100644 modules/bottom-sheet/src/BottomSheetPortal.tsx create mode 100644 modules/bottom-sheet/src/lib/Portal.tsx diff --git a/modules/bottom-sheet/index.ts b/modules/bottom-sheet/index.ts index 1fe3dac0e..4009f2ab2 100644 --- a/modules/bottom-sheet/index.ts +++ b/modules/bottom-sheet/index.ts @@ -4,9 +4,19 @@ import { BottomSheetState, BottomSheetViewProps, } from './src/BottomSheet.types' +import {BottomSheetNativeComponent} from './src/BottomSheetNativeComponent' +import { + BottomSheetOutlet, + BottomSheetPortalProvider, + BottomSheetProvider, +} from './src/BottomSheetPortal' export { BottomSheet, + BottomSheetNativeComponent, + BottomSheetOutlet, + BottomSheetPortalProvider, + BottomSheetProvider, BottomSheetSnapPoint, type BottomSheetState, type BottomSheetViewProps, diff --git a/modules/bottom-sheet/src/BottomSheet.tsx b/modules/bottom-sheet/src/BottomSheet.tsx index 9e7d0c209..bcc2c42ad 100644 --- a/modules/bottom-sheet/src/BottomSheet.tsx +++ b/modules/bottom-sheet/src/BottomSheet.tsx @@ -1,100 +1,24 @@ -import * as React from 'react' -import { - Dimensions, - NativeSyntheticEvent, - Platform, - StyleProp, - View, - ViewStyle, -} from 'react-native' -import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core' +import React from 'react' -import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types' +import {BottomSheetViewProps} from './BottomSheet.types' +import {BottomSheetNativeComponent} from './BottomSheetNativeComponent' +import {useBottomSheetPortal_INTERNAL} from './BottomSheetPortal' -const screenHeight = Dimensions.get('screen').height +export const BottomSheet = React.forwardRef< + BottomSheetNativeComponent, + BottomSheetViewProps +>(function BottomSheet(props, ref) { + const Portal = useBottomSheetPortal_INTERNAL() -const NativeView: React.ComponentType< - BottomSheetViewProps & { - ref: React.RefObject - style: StyleProp - } -> = requireNativeViewManager('BottomSheet') - -const NativeModule = requireNativeModule('BottomSheet') - -export class BottomSheet extends React.Component< - BottomSheetViewProps, - { - open: boolean - } -> { - ref = React.createRef() - - constructor(props: BottomSheetViewProps) { - super(props) - this.state = { - open: false, - } - } - - present() { - this.setState({open: true}) - } - - dismiss() { - this.ref.current?.dismiss() - } - - private onStateChange = ( - event: NativeSyntheticEvent<{state: BottomSheetState}>, - ) => { - const {state} = event.nativeEvent - const isOpen = state !== 'closed' - this.setState({open: isOpen}) - this.props.onStateChange?.(event) - } - - private updateLayout = () => { - this.ref.current?.updateLayout() - } - - static dismissAll = async () => { - await NativeModule.dismissAll() - } - - render() { - const {children, backgroundColor, ...rest} = this.props - const cornerRadius = rest.cornerRadius ?? 0 - - if (!this.state.open) { - return null - } - - return ( - - - {children} - - + if (__DEV__ && !Portal) { + throw new Error( + 'BottomSheet: You need to wrap your component tree with a to use the bottom sheet.', ) } -} + + return ( + + + + ) +}) diff --git a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx new file mode 100644 index 000000000..eadd9b4a1 --- /dev/null +++ b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx @@ -0,0 +1,103 @@ +import * as React from 'react' +import { + Dimensions, + NativeSyntheticEvent, + Platform, + StyleProp, + View, + ViewStyle, +} from 'react-native' +import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core' + +import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types' +import {BottomSheetPortalProvider} from './BottomSheetPortal' + +const screenHeight = Dimensions.get('screen').height + +const NativeView: React.ComponentType< + BottomSheetViewProps & { + ref: React.RefObject + style: StyleProp + } +> = requireNativeViewManager('BottomSheet') + +const NativeModule = requireNativeModule('BottomSheet') + +export class BottomSheetNativeComponent extends React.Component< + BottomSheetViewProps, + { + open: boolean + } +> { + ref = React.createRef() + + constructor(props: BottomSheetViewProps) { + super(props) + this.state = { + open: false, + } + } + + present() { + this.setState({open: true}) + } + + dismiss() { + this.ref.current?.dismiss() + } + + private onStateChange = ( + event: NativeSyntheticEvent<{state: BottomSheetState}>, + ) => { + const {state} = event.nativeEvent + const isOpen = state !== 'closed' + this.setState({open: isOpen}) + this.props.onStateChange?.(event) + } + + private updateLayout = () => { + this.ref.current?.updateLayout() + } + + static dismissAll = async () => { + await NativeModule.dismissAll() + } + + render() { + const {children, backgroundColor, ...rest} = this.props + const cornerRadius = rest.cornerRadius ?? 0 + + if (!this.state.open) { + return null + } + + return ( + + + + {children} + + + + ) + } +} diff --git a/modules/bottom-sheet/src/BottomSheetPortal.tsx b/modules/bottom-sheet/src/BottomSheetPortal.tsx new file mode 100644 index 000000000..da14cfa77 --- /dev/null +++ b/modules/bottom-sheet/src/BottomSheetPortal.tsx @@ -0,0 +1,40 @@ +import React from 'react' + +import {createPortalGroup_INTERNAL} from './lib/Portal' + +type PortalContext = React.ElementType<{children: React.ReactNode}> + +const Context = React.createContext({} as PortalContext) + +export const useBottomSheetPortal_INTERNAL = () => React.useContext(Context) + +export function BottomSheetPortalProvider({ + children, +}: { + children: React.ReactNode +}) { + const portal = React.useMemo(() => { + return createPortalGroup_INTERNAL() + }, []) + + return ( + + + {children} + + + + ) +} + +const defaultPortal = createPortalGroup_INTERNAL() + +export const BottomSheetOutlet = defaultPortal.Outlet + +export function BottomSheetProvider({children}: {children: React.ReactNode}) { + return ( + + {children} + + ) +} diff --git a/modules/bottom-sheet/src/lib/Portal.tsx b/modules/bottom-sheet/src/lib/Portal.tsx new file mode 100644 index 000000000..dd1bc4c13 --- /dev/null +++ b/modules/bottom-sheet/src/lib/Portal.tsx @@ -0,0 +1,67 @@ +import React from 'react' + +type Component = React.ReactElement + +type ContextType = { + outlet: Component | null + append(id: string, component: Component): void + remove(id: string): void +} + +type ComponentMap = { + [id: string]: Component +} + +export function createPortalGroup_INTERNAL() { + const Context = React.createContext({ + outlet: null, + append: () => {}, + remove: () => {}, + }) + + function Provider(props: React.PropsWithChildren<{}>) { + const map = React.useRef({}) + const [outlet, setOutlet] = React.useState(null) + + const append = React.useCallback((id, component) => { + if (map.current[id]) return + map.current[id] = {component} + setOutlet(<>{Object.values(map.current)}) + }, []) + + const remove = React.useCallback(id => { + delete map.current[id] + setOutlet(<>{Object.values(map.current)}) + }, []) + + const contextValue = React.useMemo( + () => ({ + outlet, + append, + remove, + }), + [outlet, append, remove], + ) + + return ( + {props.children} + ) + } + + function Outlet() { + const ctx = React.useContext(Context) + return ctx.outlet + } + + function Portal({children}: React.PropsWithChildren<{}>) { + const {append, remove} = React.useContext(Context) + const id = React.useId() + React.useEffect(() => { + append(id, children as Component) + return () => remove(id) + }, [id, children, append, remove]) + return null + } + + return {Provider, Outlet, Portal} +} diff --git a/src/App.native.tsx b/src/App.native.tsx index 96b493af4..0b9f112ee 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -68,6 +68,7 @@ import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' import {Provider as PortalProvider} from '#/components/Portal' import {Splash} from '#/Splash' +import {BottomSheetProvider} from '../modules/bottom-sheet' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' SplashScreen.preventAutoHideAsync() @@ -197,14 +198,16 @@ function App() { - - - - - - - + + + + + + + + + diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 49b5e10b2..6d859aeb0 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -31,12 +31,12 @@ import { DialogOuterProps, } from '#/components/Dialog/types' import {createInput} from '#/components/forms/TextField' -import {Portal as DefaultPortal} from '#/components/Portal' import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet' import { BottomSheetSnapPointChangeEvent, BottomSheetStateChangeEvent, } from '../../../modules/bottom-sheet/src/BottomSheet.types' +import {BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent' export {useDialogContext, useDialogControl} from '#/components/Dialog/context' export * from '#/components/Dialog/types' @@ -50,10 +50,9 @@ export function Outer({ onClose, nativeOptions, testID, - Portal = DefaultPortal, }: React.PropsWithChildren) { const t = useTheme() - const ref = React.useRef(null) + const ref = React.useRef(null) const closeCallbacks = React.useRef<(() => void)[]>([]) const {setDialogIsOpen, setFullyExpandedCount} = useDialogStateControlContext() @@ -154,20 +153,18 @@ export function Outer({ ) return ( - - - - {children} - - - + + + {children} + + ) } diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx index c0ed202da..765e9415d 100644 --- a/src/components/dialogs/GifSelect.tsx +++ b/src/components/dialogs/GifSelect.tsx @@ -30,18 +30,15 @@ import {useThrottledValue} from '#/components/hooks/useThrottledValue' import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' -import {PortalComponent} from '#/components/Portal' export function GifSelectDialog({ controlRef, onClose, onSelectGif: onSelectGifProp, - Portal, }: { controlRef: React.RefObject<{open: () => void}> onClose: () => void onSelectGif: (gif: Gif) => void - Portal?: PortalComponent }) { const control = Dialog.useDialogControl() @@ -65,7 +62,6 @@ export function GifSelectDialog({ - - - - Add muted words and tags - - - - Posts can be muted based on their text, their tags, or both. We - recommend avoiding common words that appear in many posts, since - it can result in no posts being shown. - - - - - { - if (error) { - setError('') - } - setField(value) - }} - onSubmitEditing={submit} - /> - + + + + Add muted words and tags + + + + Posts can be muted based on their text, their tags, or both. We + recommend avoiding common words that appear in many posts, since it + can result in no posts being shown. + + + + + { + if (error) { + setError('') + } + setField(value) + }} + onSubmitEditing={submit} + /> + - - - - Duration: - + + + + Duration: + + - - - - - - - Forever - - - - - - - - - - - 24 hours - - - - - + + + + + + Forever + + + + - - - - - - - 7 days - - - - - - - - - - - 30 days - - - - - + + + + + + 24 hours + + + + - - - - Mute in: - - - - Text & tags + 7 days - - Tags only + 30 days - - + + - - - Options: - + + + Mute in: + + + + label={_(msg`Mute this word in post text and tags`)} + name="content" + style={[a.flex_1]}> - + - Exclude users you follow + Text & tags + - - - + + + + + + Tags only + + + + + + - {error && ( - - - {error} - - - )} - - - - - + - Your muted words + Options: + + + + + + Exclude users you follow + + + + + - {isPreferencesLoading ? ( - - ) : preferencesError || !preferences ? ( - - - - We're sorry, but we weren't able to load your muted words at - this time. Please try again. - - - - ) : preferences.moderationPrefs.mutedWords.length ? ( - [...preferences.moderationPrefs.mutedWords] - .reverse() - .map((word, i) => ( - - )) - ) : ( - + + + + {error && ( + + - - You haven't muted any words or tags yet - - - )} - + {error} + + + )} + + + - {isNative && } + + + Your muted words + + + {isPreferencesLoading ? ( + + ) : preferencesError || !preferences ? ( + + + + We're sorry, but we weren't able to load your muted words at + this time. Please try again. + + + + ) : preferences.moderationPrefs.mutedWords.length ? ( + [...preferences.moderationPrefs.mutedWords] + .reverse() + .map((word, i) => ( + + )) + ) : ( + + + You haven't muted any words or tags yet + + + )} - - + {isNative && } + - - + + ) } @@ -482,7 +437,6 @@ function MutedWordRow({ onConfirm={remove} confirmButtonCta={_(msg`Remove`)} confirmButtonColor="negative" - Portal={Portal.Portal} /> + {isLoading ? ( - + + + ) : ( ) { return openDialogs.current.size > 0 } else { - BottomSheet.dismissAll() + BottomSheetNativeComponent.dismissAll() return false } }, []) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 8cc8fba0d..49a498cce 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -107,9 +107,9 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' -import {createPortalGroup} from '#/components/Portal' import * as Prompt from '#/components/Prompt' import {Text as NewText} from '#/components/Typography' +import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' import { composerReducer, createComposerState, @@ -117,8 +117,6 @@ import { } from './state/composer' import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video' -const Portal = createPortalGroup() - type CancelRef = { onPressCancel: () => void } @@ -522,7 +520,7 @@ export const ComposePost = ({ const keyboardVerticalOffset = useKeyboardVerticalOffset() return ( - + - + {extGif && ( @@ -684,7 +678,6 @@ export const ComposePost = ({ gif={extGif} altText={extGifAlt ?? ''} onSubmit={handleChangeGifAltText} - Portal={Portal.Portal} /> )} @@ -744,7 +737,6 @@ export const ComposePost = ({ }, }) }} - Portal={Portal.Portal} /> )} @@ -782,7 +774,6 @@ export const ComposePost = ({ }) }} style={bottomBarAnimatedStyle} - Portal={Portal.Portal} /> )} {!isMobile ? ( - + diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx index e6fcef555..343d7f07b 100644 --- a/src/view/screens/Storybook/Dialogs.tsx +++ b/src/view/screens/Storybook/Dialogs.tsx @@ -8,13 +8,10 @@ import {atoms as a} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import * as Menu from '#/components/Menu' -import {createPortalGroup} from '#/components/Portal' import * as Prompt from '#/components/Prompt' import {H3, P, Text} from '#/components/Typography' import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army' -const Portal = createPortalGroup() - export function Dialogs() { const scrollable = Dialog.useDialogControl() const basic = Dialog.useDialogControl() @@ -201,41 +198,34 @@ export function Dialogs() { - - -

Dialog with Menu

- - - {({props}) => ( - - )} - - - - console.log('item 1')}> - Item 1 - - console.log('item 2')}> - Item 2 - - - - -
- -
+ +

Dialog with Menu

+ + + {({props}) => ( + + )} + + + + console.log('item 1')}> + Item 1 + + console.log('item 2')}> + Item 2 + + + + +
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 8bc3de24d..9f7569beb 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -34,6 +34,7 @@ import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {MutedWordsDialog} from '#/components/dialogs/MutedWords' import {SigninDialog} from '#/components/dialogs/Signin' import {Outlet as PortalOutlet} from '#/components/Portal' +import {BottomSheetOutlet} from '../../../modules/bottom-sheet' import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView' import {RoutesContainer, TabsNavigator} from '../../Navigation' import {Composer} from './Composer' @@ -119,6 +120,7 @@ function ShellInner() { + ) } -- cgit 1.4.1