Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions package/src/components/UIComponents/PortalWhileClosingView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Portal } from 'react-native-teleport';

import { measureInWindow } from '../../components/Message/utils/measureInWindow';
import { useOverlayHostLayerId } from '../../contexts/overlayContext/OverlayHostLayerContext';
import { useStableCallback } from '../../hooks';
import {
clearClosingPortalLayout,
Expand All @@ -14,6 +15,7 @@ import {
useShouldTeleportToClosingPortal,
useHasActiveId,
useIsOverlayClosing,
useIsTopOverlayHostLayer,
} from '../../state-store';
import type { ViewRef } from '../../types/react-native-compat';

Expand Down Expand Up @@ -79,11 +81,16 @@ export const PortalWhileClosingView = ({

const registrationId = registrationIdRef.current;

const overlayHostLayerId = useOverlayHostLayerId();
const isTopOverlayHostLayer = useIsTopOverlayHostLayer(overlayHostLayerId);

const { syncPortalLayout, containerRef, placeholderLayout } = useSyncingApi(
portalHostName,
registrationId,
isTopOverlayHostLayer,
);
const shouldTeleport = useShouldTeleportToClosingPortal(portalHostName, registrationId);
const shouldTeleport =
useShouldTeleportToClosingPortal(portalHostName, registrationId) && isTopOverlayHostLayer;

useEffect(() => {
return () => {
Expand Down Expand Up @@ -114,15 +121,19 @@ export const PortalWhileClosingView = ({
);
};

const useSyncingApi = (portalHostName: string, registrationId: string) => {
const useSyncingApi = (
portalHostName: string,
registrationId: string,
isTopOverlayHostLayer: boolean,
) => {
const containerRef = useRef<ViewRef | null>(null);
const placeholderLayout = useSharedValue({ h: 0, w: 0 });
const insets = useSafeAreaInsets();
const hasActiveId = useHasActiveId();
const isClosing = useIsOverlayClosing();

const syncPortalLayout = useStableCallback(() => {
if (!hasActiveId && !isClosing) {
if ((!hasActiveId && !isClosing) || !isTopOverlayHostLayer) {
return;
}

Expand All @@ -140,7 +151,7 @@ const useSyncingApi = (portalHostName: string, registrationId: string) => {
if (hasActiveId || isClosing) {
syncPortalLayout();
}
}, [insets.bottom, isClosing, hasActiveId, syncPortalLayout]);
}, [insets.bottom, isClosing, hasActiveId, isTopOverlayHostLayer, syncPortalLayout]);

return useMemo(
() => ({ syncPortalLayout, containerRef, placeholderLayout }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Text } from 'react-native';

import { act, cleanup, render, screen } from '@testing-library/react-native';

import { OverlayHostLayerContext } from '../../../contexts/overlayContext/OverlayHostLayerContext';
import * as stateStore from '../../../state-store';
import { PortalWhileClosingView } from '../PortalWhileClosingView';

Expand Down Expand Up @@ -172,4 +173,41 @@ describe('PortalWhileClosingView', () => {

expect(screen.getByTestId('teleport-state')).toHaveTextContent('false');
});

it('stays in place while its overlay provider is not the top one', () => {
let unregisterRootLayer = () => {};
let unregisterNestedLayer = () => {};

act(() => {
unregisterRootLayer = stateStore.registerOverlayHostLayer('root-layer');
unregisterNestedLayer = stateStore.registerOverlayHostLayer('nested-layer');
});

render(
<OverlayHostLayerContext.Provider value='root-layer'>
<PortalWhileClosingView portalHostName='overlay-composer' portalName='composer-portal'>
<Text>Composer</Text>
</PortalWhileClosingView>
</OverlayHostLayerContext.Provider>,
);

act(() => {
stateStore.setClosingPortalLayout('overlay-composer', 'registration-1', BASE_RECT);
stateStore.openOverlay('message-1');
stateStore.closeOverlay();
});
flushAnimationFrameQueue();

expect(screen.queryByTestId('portal-while-closing-placeholder-composer-portal')).toBeNull();

act(() => {
unregisterNestedLayer();
});

expect(screen.getByTestId('portal-while-closing-placeholder-composer-portal')).toBeTruthy();

act(() => {
unregisterRootLayer();
});
});
});
20 changes: 18 additions & 2 deletions package/src/contexts/overlayContext/ClosingPortalHostsLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ type ClosingPortalHostSlotProps = {
closeCoverOpacity: SharedValue<number>;
hostName: string;
layout: ClosingPortalLayoutEntry['layout'];
portalHostName: string;
};

const ClosingPortalHostSlot = ({
closeCoverOpacity,
hostName,
layout,
portalHostName,
}: ClosingPortalHostSlotProps) => {
const style = useAnimatedStyle(() => {
const value = layout.value;
Expand All @@ -36,16 +38,29 @@ const ClosingPortalHostSlot = ({
style={style}
testID={`closing-portal-host-slot-${hostName}`}
>
<PortalHost name={hostName} style={styles.absoluteFill} />
<PortalHost name={portalHostName} style={styles.absoluteFill} />
</Animated.View>
);
};

type ClosingPortalHostsLayerProps = {
closeCoverOpacity: SharedValue<number>;
/**
* Id of the owning host layer, used to park the slot hosts while that layer is not the top one.
*/
hostLayerId?: string;
/**
* Whether the owning host layer is the top one. Slots of any other layer park their hosts under
* names unique to that layer, so they never take the shared names from the top layer.
*/
isTopHostLayer?: boolean;
};

export const ClosingPortalHostsLayer = ({ closeCoverOpacity }: ClosingPortalHostsLayerProps) => {
export const ClosingPortalHostsLayer = ({
closeCoverOpacity,
hostLayerId,
isTopHostLayer = true,
}: ClosingPortalHostsLayerProps) => {
const closingPortalLayoutStacks = useClosingPortalLayouts();
const closingPortalHosts = useMemo(() => {
const topHosts: Array<{
Expand All @@ -71,6 +86,7 @@ export const ClosingPortalHostsLayer = ({ closeCoverOpacity }: ClosingPortalHost
hostName={hostName}
key={hostName}
layout={layout}
portalHostName={isTopHostLayer ? hostName : `${hostName}:${hostLayerId}`}
/>
))}
</>
Expand Down
63 changes: 47 additions & 16 deletions package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef } from 'react';
import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import {
BackHandler,
I18nManager,
Expand Down Expand Up @@ -33,8 +33,10 @@ import { useScreenOrientation } from '../../hooks';
import {
closeOverlay,
finalizeCloseOverlay,
registerOverlayHostLayer,
registerOverlaySharedValueController,
Rect,
useIsTopOverlayHostLayer,
useOverlayController,
} from '../../state-store';
import { useComponentsContext } from '../componentsContext/ComponentsContext';
Expand Down Expand Up @@ -69,9 +71,21 @@ export type MessageActionsProps = {
topItemStyle: StyleProp<AnimatedStyle<ViewStyle>>;
};

export const MessageOverlayHostLayer = () => {
type MessageOverlayHostLayerProps = {
/**
* Id under which this layer registers on the host layer stack. `OverlayProvider` passes its own
* so that views inside the provider can tell whether this layer is the top one.
*/
hostLayerId: string;
};

export const MessageOverlayHostLayer = ({ hostLayerId }: MessageOverlayHostLayerProps) => {
const { MessageActions, MessageOverlayBackground } = useComponentsContext();
const { id, closing } = useOverlayController();

useLayoutEffect(() => registerOverlayHostLayer(hostLayerId), [hostLayerId]);

const isTop = useIsTopOverlayHostLayer(hostLayerId);
const insets = useSafeAreaInsets();
const { height: screenH } = useWindowDimensions();
const messageH = useSharedValue<Rect>(undefined);
Expand All @@ -86,7 +100,7 @@ export const MessageOverlayHostLayer = () => {
// if the navigation bar is actually 0 - we end up animating a little bit further.
const bottomInset = insets.bottom === 0 && Platform.OS === 'android' ? 60 : insets.bottom;

const isActive = !!id;
const isActive = !!id && isTop;

const announce = useAccessibilityAnnouncer();
const overlayOpenHint = useA11yLabel('a11y/Swipe right to go through different actions');
Expand Down Expand Up @@ -127,8 +141,10 @@ export const MessageOverlayHostLayer = () => {
return;
}
prevOrientationRef.current = orientation;
finalizeCloseOverlay();
}, [orientation]);
if (isTop) {
finalizeCloseOverlay();
}
}, [isTop, orientation]);

const padding = 8;
const minY = topInset + padding;
Expand Down Expand Up @@ -201,14 +217,17 @@ export const MessageOverlayHostLayer = () => {
);

useEffect(() => {
if (!isTop) {
return;
}
const target = isActive && !closing ? 1 : 0;
backdrop.value = withSpring(target, { duration: DURATION + target * 100 }, (finished) => {
if (finished && closing) {
runOnJS(finalizeCloseOverlay)();
}
});
closeCoverOpacity.value = withSpring(closing ? 1 : 0, { duration: DURATION });
}, [isActive, closing, backdrop, closeCoverOpacity]);
}, [isActive, isTop, closing, backdrop, closeCoverOpacity]);

const backdropStyle = useAnimatedStyle(() => ({
opacity: backdrop.value,
Expand Down Expand Up @@ -375,6 +394,10 @@ export const MessageOverlayHostLayer = () => {
[bottomH, bottomShiftY, messageShiftY, pan, topH],
);

const messageHostName = isTop ? 'message-overlay' : `message-overlay:${hostLayerId}`;
const topItemHostName = isTop ? 'top-item' : `top-item:${hostLayerId}`;
const bottomItemHostName = isTop ? 'bottom-item' : `bottom-item:${hostLayerId}`;

return (
<GestureDetector gesture={tap}>
<View
Expand All @@ -398,12 +421,16 @@ export const MessageOverlayHostLayer = () => {
) : null}

{MessageActions ? (
<MessageActions
bottomItemStyle={bottomItemStyle}
hostStyle={hostStyle}
portalHostStyle={styles.absoluteFill}
topItemStyle={topItemStyle}
/>
// A custom `MessageActions` renders its own hosts with the shared names, so only the
// top layer renders it.
isTop ? (
<MessageActions
bottomItemStyle={bottomItemStyle}
hostStyle={hostStyle}
portalHostStyle={styles.absoluteFill}
topItemStyle={topItemStyle}
/>
) : null
) : (
<>
{/*
Expand All @@ -412,22 +439,26 @@ export const MessageOverlayHostLayer = () => {
*/}
<GestureDetector gesture={pan}>
<Animated.View style={hostStyle} testID='message-overlay-message'>
<PortalHost name='message-overlay' style={styles.absoluteFill} />
<PortalHost name={messageHostName} style={styles.absoluteFill} />
</Animated.View>
</GestureDetector>

<Animated.View style={topItemStyle} testID='message-overlay-top'>
<PortalHost name='top-item' style={styles.absoluteFill} />
<PortalHost name={topItemHostName} style={styles.absoluteFill} />
</Animated.View>

<Animated.View style={bottomItemStyle} testID='message-overlay-bottom'>
<PortalHost name='bottom-item' style={styles.absoluteFill} />
<PortalHost name={bottomItemHostName} style={styles.absoluteFill} />
</Animated.View>
</>
)}
</View>

<ClosingPortalHostsLayer closeCoverOpacity={closeCoverOpacity} />
<ClosingPortalHostsLayer
closeCoverOpacity={closeCoverOpacity}
hostLayerId={hostLayerId}
isTopHostLayer={isTop}
/>
</View>
</GestureDetector>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React, { useContext } from 'react';

/**
* The id of the `MessageOverlayHostLayer` rendered by the nearest `OverlayProvider`. Views that
* teleport into that layer use it to tell whether their provider currently owns the overlay.
*/
export const OverlayHostLayerContext = React.createContext<string | undefined>(undefined);

export const useOverlayHostLayerId = () => useContext(OverlayHostLayerContext);
19 changes: 12 additions & 7 deletions package/src/contexts/overlayContext/OverlayProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import { PortalProvider } from 'react-native-teleport';

import { MessageOverlayHostLayer } from './MessageOverlayHostLayer';
import { OverlayContext, OverlayProviderProps } from './OverlayContext';
import { OverlayHostLayerContext } from './OverlayHostLayerContext';

import { OverlayA11yShield } from '../../components/Accessibility/OverlayA11yShield';
import { ImageGallery } from '../../components/ImageGallery/ImageGallery';
import { useStreami18n } from '../../hooks/useStreami18n';
import { createOverlayHostLayerId } from '../../state-store';

import { AccessibilityProvider } from '../accessibilityContext/AccessibilityContext';
import { ImageGalleryProvider } from '../imageGalleryContext/ImageGalleryContext';
Expand Down Expand Up @@ -55,6 +57,7 @@ export const OverlayProvider = (props: PropsWithChildren<OverlayProviderProps>)
} = props;

const [overlay, setOverlay] = useState(value?.overlay || 'none');
const [overlayHostLayerId] = useState(createOverlayHostLayerId);

const overlayOpacity = useSharedValue(0);

Expand Down Expand Up @@ -110,13 +113,15 @@ export const OverlayProvider = (props: PropsWithChildren<OverlayProviderProps>)
<ImageGalleryProvider value={imageGalleryProviderProps}>
<ThemeProvider style={overlayContext.style}>
<PortalProvider>
{accessibility?.enabled ? (
<OverlayA11yShield>{children}</OverlayA11yShield>
) : (
children
)}
{overlay === 'gallery' && <ImageGallery overlayOpacity={overlayOpacity} />}
<MessageOverlayHostLayer />
<OverlayHostLayerContext.Provider value={overlayHostLayerId}>
{accessibility?.enabled ? (
<OverlayA11yShield>{children}</OverlayA11yShield>
) : (
children
)}
{overlay === 'gallery' && <ImageGallery overlayOpacity={overlayOpacity} />}
<MessageOverlayHostLayer hostLayerId={overlayHostLayerId} />
</OverlayHostLayerContext.Provider>
</PortalProvider>
</ThemeProvider>
</ImageGalleryProvider>
Expand Down
Loading
Loading