stream-chat-react-native-core 9.10.0 → 9.10.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/lib/commonjs/components/UIComponents/PortalWhileClosingView.js +8 -5
  2. package/lib/commonjs/components/UIComponents/PortalWhileClosingView.js.map +1 -1
  3. package/lib/commonjs/contexts/overlayContext/ClosingPortalHostsLayer.js +8 -4
  4. package/lib/commonjs/contexts/overlayContext/ClosingPortalHostsLayer.js.map +1 -1
  5. package/lib/commonjs/contexts/overlayContext/MessageOverlayHostLayer.js +25 -11
  6. package/lib/commonjs/contexts/overlayContext/MessageOverlayHostLayer.js.map +1 -1
  7. package/lib/commonjs/contexts/overlayContext/OverlayHostLayerContext.js +10 -0
  8. package/lib/commonjs/contexts/overlayContext/OverlayHostLayerContext.js.map +1 -0
  9. package/lib/commonjs/contexts/overlayContext/OverlayProvider.js +16 -6
  10. package/lib/commonjs/contexts/overlayContext/OverlayProvider.js.map +1 -1
  11. package/lib/commonjs/state-store/message-overlay-store.js +47 -18
  12. package/lib/commonjs/state-store/message-overlay-store.js.map +1 -1
  13. package/lib/commonjs/version.json +1 -1
  14. package/lib/module/components/UIComponents/PortalWhileClosingView.js +8 -5
  15. package/lib/module/components/UIComponents/PortalWhileClosingView.js.map +1 -1
  16. package/lib/module/contexts/overlayContext/ClosingPortalHostsLayer.js +8 -4
  17. package/lib/module/contexts/overlayContext/ClosingPortalHostsLayer.js.map +1 -1
  18. package/lib/module/contexts/overlayContext/MessageOverlayHostLayer.js +25 -11
  19. package/lib/module/contexts/overlayContext/MessageOverlayHostLayer.js.map +1 -1
  20. package/lib/module/contexts/overlayContext/OverlayHostLayerContext.js +10 -0
  21. package/lib/module/contexts/overlayContext/OverlayHostLayerContext.js.map +1 -0
  22. package/lib/module/contexts/overlayContext/OverlayProvider.js +16 -6
  23. package/lib/module/contexts/overlayContext/OverlayProvider.js.map +1 -1
  24. package/lib/module/state-store/message-overlay-store.js +47 -18
  25. package/lib/module/state-store/message-overlay-store.js.map +1 -1
  26. package/lib/module/version.json +1 -1
  27. package/lib/typescript/components/UIComponents/PortalWhileClosingView.d.ts.map +1 -1
  28. package/lib/typescript/contexts/overlayContext/ClosingPortalHostsLayer.d.ts +10 -1
  29. package/lib/typescript/contexts/overlayContext/ClosingPortalHostsLayer.d.ts.map +1 -1
  30. package/lib/typescript/contexts/overlayContext/MessageOverlayHostLayer.d.ts +9 -1
  31. package/lib/typescript/contexts/overlayContext/MessageOverlayHostLayer.d.ts.map +1 -1
  32. package/lib/typescript/contexts/overlayContext/OverlayHostLayerContext.d.ts +8 -0
  33. package/lib/typescript/contexts/overlayContext/OverlayHostLayerContext.d.ts.map +1 -0
  34. package/lib/typescript/contexts/overlayContext/OverlayProvider.d.ts.map +1 -1
  35. package/lib/typescript/state-store/message-overlay-store.d.ts +16 -0
  36. package/lib/typescript/state-store/message-overlay-store.d.ts.map +1 -1
  37. package/package.json +14 -14
  38. package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.tsx.snap +1809 -1704
  39. package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.tsx.snap +1208 -1138
  40. package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.tsx.snap +128 -121
  41. package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap +722 -680
  42. package/src/components/UIComponents/PortalWhileClosingView.tsx +15 -4
  43. package/src/components/UIComponents/__tests__/PortalWhileClosingView.test.tsx +38 -0
  44. package/src/contexts/overlayContext/ClosingPortalHostsLayer.tsx +18 -2
  45. package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx +47 -16
  46. package/src/contexts/overlayContext/OverlayHostLayerContext.ts +9 -0
  47. package/src/contexts/overlayContext/OverlayProvider.tsx +12 -7
  48. package/src/contexts/overlayContext/__tests__/ClosingPortalHostsLayer.test.tsx +27 -0
  49. package/src/contexts/overlayContext/__tests__/MessageOverlayHostLayer.test.tsx +84 -6
  50. package/src/state-store/__tests__/message-overlay-store.test.tsx +97 -0
  51. package/src/state-store/message-overlay-store.ts +70 -11
  52. package/src/version.json +1 -1
@@ -6,6 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
6
6
  import { Portal } from 'react-native-teleport';
7
7
 
8
8
  import { measureInWindow } from '../../components/Message/utils/measureInWindow';
9
+ import { useOverlayHostLayerId } from '../../contexts/overlayContext/OverlayHostLayerContext';
9
10
  import { useStableCallback } from '../../hooks';
10
11
  import {
11
12
  clearClosingPortalLayout,
@@ -14,6 +15,7 @@ import {
14
15
  useShouldTeleportToClosingPortal,
15
16
  useHasActiveId,
16
17
  useIsOverlayClosing,
18
+ useIsTopOverlayHostLayer,
17
19
  } from '../../state-store';
18
20
  import type { ViewRef } from '../../types/react-native-compat';
19
21
 
@@ -79,11 +81,16 @@ export const PortalWhileClosingView = ({
79
81
 
80
82
  const registrationId = registrationIdRef.current;
81
83
 
84
+ const overlayHostLayerId = useOverlayHostLayerId();
85
+ const isTopOverlayHostLayer = useIsTopOverlayHostLayer(overlayHostLayerId);
86
+
82
87
  const { syncPortalLayout, containerRef, placeholderLayout } = useSyncingApi(
83
88
  portalHostName,
84
89
  registrationId,
90
+ isTopOverlayHostLayer,
85
91
  );
86
- const shouldTeleport = useShouldTeleportToClosingPortal(portalHostName, registrationId);
92
+ const shouldTeleport =
93
+ useShouldTeleportToClosingPortal(portalHostName, registrationId) && isTopOverlayHostLayer;
87
94
 
88
95
  useEffect(() => {
89
96
  return () => {
@@ -114,7 +121,11 @@ export const PortalWhileClosingView = ({
114
121
  );
115
122
  };
116
123
 
117
- const useSyncingApi = (portalHostName: string, registrationId: string) => {
124
+ const useSyncingApi = (
125
+ portalHostName: string,
126
+ registrationId: string,
127
+ isTopOverlayHostLayer: boolean,
128
+ ) => {
118
129
  const containerRef = useRef<ViewRef | null>(null);
119
130
  const placeholderLayout = useSharedValue({ h: 0, w: 0 });
120
131
  const insets = useSafeAreaInsets();
@@ -122,7 +133,7 @@ const useSyncingApi = (portalHostName: string, registrationId: string) => {
122
133
  const isClosing = useIsOverlayClosing();
123
134
 
124
135
  const syncPortalLayout = useStableCallback(() => {
125
- if (!hasActiveId && !isClosing) {
136
+ if ((!hasActiveId && !isClosing) || !isTopOverlayHostLayer) {
126
137
  return;
127
138
  }
128
139
 
@@ -140,7 +151,7 @@ const useSyncingApi = (portalHostName: string, registrationId: string) => {
140
151
  if (hasActiveId || isClosing) {
141
152
  syncPortalLayout();
142
153
  }
143
- }, [insets.bottom, isClosing, hasActiveId, syncPortalLayout]);
154
+ }, [insets.bottom, isClosing, hasActiveId, isTopOverlayHostLayer, syncPortalLayout]);
144
155
 
145
156
  return useMemo(
146
157
  () => ({ syncPortalLayout, containerRef, placeholderLayout }),
@@ -3,6 +3,7 @@ import { Text } from 'react-native';
3
3
 
4
4
  import { act, cleanup, render, screen } from '@testing-library/react-native';
5
5
 
6
+ import { OverlayHostLayerContext } from '../../../contexts/overlayContext/OverlayHostLayerContext';
6
7
  import * as stateStore from '../../../state-store';
7
8
  import { PortalWhileClosingView } from '../PortalWhileClosingView';
8
9
 
@@ -172,4 +173,41 @@ describe('PortalWhileClosingView', () => {
172
173
 
173
174
  expect(screen.getByTestId('teleport-state')).toHaveTextContent('false');
174
175
  });
176
+
177
+ it('stays in place while its overlay provider is not the top one', () => {
178
+ let unregisterRootLayer = () => {};
179
+ let unregisterNestedLayer = () => {};
180
+
181
+ act(() => {
182
+ unregisterRootLayer = stateStore.registerOverlayHostLayer('root-layer');
183
+ unregisterNestedLayer = stateStore.registerOverlayHostLayer('nested-layer');
184
+ });
185
+
186
+ render(
187
+ <OverlayHostLayerContext.Provider value='root-layer'>
188
+ <PortalWhileClosingView portalHostName='overlay-composer' portalName='composer-portal'>
189
+ <Text>Composer</Text>
190
+ </PortalWhileClosingView>
191
+ </OverlayHostLayerContext.Provider>,
192
+ );
193
+
194
+ act(() => {
195
+ stateStore.setClosingPortalLayout('overlay-composer', 'registration-1', BASE_RECT);
196
+ stateStore.openOverlay('message-1');
197
+ stateStore.closeOverlay();
198
+ });
199
+ flushAnimationFrameQueue();
200
+
201
+ expect(screen.queryByTestId('portal-while-closing-placeholder-composer-portal')).toBeNull();
202
+
203
+ act(() => {
204
+ unregisterNestedLayer();
205
+ });
206
+
207
+ expect(screen.getByTestId('portal-while-closing-placeholder-composer-portal')).toBeTruthy();
208
+
209
+ act(() => {
210
+ unregisterRootLayer();
211
+ });
212
+ });
175
213
  });
@@ -9,12 +9,14 @@ type ClosingPortalHostSlotProps = {
9
9
  closeCoverOpacity: SharedValue<number>;
10
10
  hostName: string;
11
11
  layout: ClosingPortalLayoutEntry['layout'];
12
+ portalHostName: string;
12
13
  };
13
14
 
14
15
  const ClosingPortalHostSlot = ({
15
16
  closeCoverOpacity,
16
17
  hostName,
17
18
  layout,
19
+ portalHostName,
18
20
  }: ClosingPortalHostSlotProps) => {
19
21
  const style = useAnimatedStyle(() => {
20
22
  const value = layout.value;
@@ -36,16 +38,29 @@ const ClosingPortalHostSlot = ({
36
38
  style={style}
37
39
  testID={`closing-portal-host-slot-${hostName}`}
38
40
  >
39
- <PortalHost name={hostName} style={styles.absoluteFill} />
41
+ <PortalHost name={portalHostName} style={styles.absoluteFill} />
40
42
  </Animated.View>
41
43
  );
42
44
  };
43
45
 
44
46
  type ClosingPortalHostsLayerProps = {
45
47
  closeCoverOpacity: SharedValue<number>;
48
+ /**
49
+ * Id of the owning host layer, used to park the slot hosts while that layer is not the top one.
50
+ */
51
+ hostLayerId?: string;
52
+ /**
53
+ * Whether the owning host layer is the top one. Slots of any other layer park their hosts under
54
+ * names unique to that layer, so they never take the shared names from the top layer.
55
+ */
56
+ isTopHostLayer?: boolean;
46
57
  };
47
58
 
48
- export const ClosingPortalHostsLayer = ({ closeCoverOpacity }: ClosingPortalHostsLayerProps) => {
59
+ export const ClosingPortalHostsLayer = ({
60
+ closeCoverOpacity,
61
+ hostLayerId,
62
+ isTopHostLayer = true,
63
+ }: ClosingPortalHostsLayerProps) => {
49
64
  const closingPortalLayoutStacks = useClosingPortalLayouts();
50
65
  const closingPortalHosts = useMemo(() => {
51
66
  const topHosts: Array<{
@@ -71,6 +86,7 @@ export const ClosingPortalHostsLayer = ({ closeCoverOpacity }: ClosingPortalHost
71
86
  hostName={hostName}
72
87
  key={hostName}
73
88
  layout={layout}
89
+ portalHostName={isTopHostLayer ? hostName : `${hostName}:${hostLayerId}`}
74
90
  />
75
91
  ))}
76
92
  </>
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useMemo, useRef } from 'react';
1
+ import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
2
2
  import {
3
3
  BackHandler,
4
4
  I18nManager,
@@ -33,8 +33,10 @@ import { useScreenOrientation } from '../../hooks';
33
33
  import {
34
34
  closeOverlay,
35
35
  finalizeCloseOverlay,
36
+ registerOverlayHostLayer,
36
37
  registerOverlaySharedValueController,
37
38
  Rect,
39
+ useIsTopOverlayHostLayer,
38
40
  useOverlayController,
39
41
  } from '../../state-store';
40
42
  import { useComponentsContext } from '../componentsContext/ComponentsContext';
@@ -69,9 +71,21 @@ export type MessageActionsProps = {
69
71
  topItemStyle: StyleProp<AnimatedStyle<ViewStyle>>;
70
72
  };
71
73
 
72
- export const MessageOverlayHostLayer = () => {
74
+ type MessageOverlayHostLayerProps = {
75
+ /**
76
+ * Id under which this layer registers on the host layer stack. `OverlayProvider` passes its own
77
+ * so that views inside the provider can tell whether this layer is the top one.
78
+ */
79
+ hostLayerId: string;
80
+ };
81
+
82
+ export const MessageOverlayHostLayer = ({ hostLayerId }: MessageOverlayHostLayerProps) => {
73
83
  const { MessageActions, MessageOverlayBackground } = useComponentsContext();
74
84
  const { id, closing } = useOverlayController();
85
+
86
+ useLayoutEffect(() => registerOverlayHostLayer(hostLayerId), [hostLayerId]);
87
+
88
+ const isTop = useIsTopOverlayHostLayer(hostLayerId);
75
89
  const insets = useSafeAreaInsets();
76
90
  const { height: screenH } = useWindowDimensions();
77
91
  const messageH = useSharedValue<Rect>(undefined);
@@ -86,7 +100,7 @@ export const MessageOverlayHostLayer = () => {
86
100
  // if the navigation bar is actually 0 - we end up animating a little bit further.
87
101
  const bottomInset = insets.bottom === 0 && Platform.OS === 'android' ? 60 : insets.bottom;
88
102
 
89
- const isActive = !!id;
103
+ const isActive = !!id && isTop;
90
104
 
91
105
  const announce = useAccessibilityAnnouncer();
92
106
  const overlayOpenHint = useA11yLabel('a11y/Swipe right to go through different actions');
@@ -127,8 +141,10 @@ export const MessageOverlayHostLayer = () => {
127
141
  return;
128
142
  }
129
143
  prevOrientationRef.current = orientation;
130
- finalizeCloseOverlay();
131
- }, [orientation]);
144
+ if (isTop) {
145
+ finalizeCloseOverlay();
146
+ }
147
+ }, [isTop, orientation]);
132
148
 
133
149
  const padding = 8;
134
150
  const minY = topInset + padding;
@@ -201,6 +217,9 @@ export const MessageOverlayHostLayer = () => {
201
217
  );
202
218
 
203
219
  useEffect(() => {
220
+ if (!isTop) {
221
+ return;
222
+ }
204
223
  const target = isActive && !closing ? 1 : 0;
205
224
  backdrop.value = withSpring(target, { duration: DURATION + target * 100 }, (finished) => {
206
225
  if (finished && closing) {
@@ -208,7 +227,7 @@ export const MessageOverlayHostLayer = () => {
208
227
  }
209
228
  });
210
229
  closeCoverOpacity.value = withSpring(closing ? 1 : 0, { duration: DURATION });
211
- }, [isActive, closing, backdrop, closeCoverOpacity]);
230
+ }, [isActive, isTop, closing, backdrop, closeCoverOpacity]);
212
231
 
213
232
  const backdropStyle = useAnimatedStyle(() => ({
214
233
  opacity: backdrop.value,
@@ -375,6 +394,10 @@ export const MessageOverlayHostLayer = () => {
375
394
  [bottomH, bottomShiftY, messageShiftY, pan, topH],
376
395
  );
377
396
 
397
+ const messageHostName = isTop ? 'message-overlay' : `message-overlay:${hostLayerId}`;
398
+ const topItemHostName = isTop ? 'top-item' : `top-item:${hostLayerId}`;
399
+ const bottomItemHostName = isTop ? 'bottom-item' : `bottom-item:${hostLayerId}`;
400
+
378
401
  return (
379
402
  <GestureDetector gesture={tap}>
380
403
  <View
@@ -398,12 +421,16 @@ export const MessageOverlayHostLayer = () => {
398
421
  ) : null}
399
422
 
400
423
  {MessageActions ? (
401
- <MessageActions
402
- bottomItemStyle={bottomItemStyle}
403
- hostStyle={hostStyle}
404
- portalHostStyle={styles.absoluteFill}
405
- topItemStyle={topItemStyle}
406
- />
424
+ // A custom `MessageActions` renders its own hosts with the shared names, so only the
425
+ // top layer renders it.
426
+ isTop ? (
427
+ <MessageActions
428
+ bottomItemStyle={bottomItemStyle}
429
+ hostStyle={hostStyle}
430
+ portalHostStyle={styles.absoluteFill}
431
+ topItemStyle={topItemStyle}
432
+ />
433
+ ) : null
407
434
  ) : (
408
435
  <>
409
436
  {/*
@@ -412,22 +439,26 @@ export const MessageOverlayHostLayer = () => {
412
439
  */}
413
440
  <GestureDetector gesture={pan}>
414
441
  <Animated.View style={hostStyle} testID='message-overlay-message'>
415
- <PortalHost name='message-overlay' style={styles.absoluteFill} />
442
+ <PortalHost name={messageHostName} style={styles.absoluteFill} />
416
443
  </Animated.View>
417
444
  </GestureDetector>
418
445
 
419
446
  <Animated.View style={topItemStyle} testID='message-overlay-top'>
420
- <PortalHost name='top-item' style={styles.absoluteFill} />
447
+ <PortalHost name={topItemHostName} style={styles.absoluteFill} />
421
448
  </Animated.View>
422
449
 
423
450
  <Animated.View style={bottomItemStyle} testID='message-overlay-bottom'>
424
- <PortalHost name='bottom-item' style={styles.absoluteFill} />
451
+ <PortalHost name={bottomItemHostName} style={styles.absoluteFill} />
425
452
  </Animated.View>
426
453
  </>
427
454
  )}
428
455
  </View>
429
456
 
430
- <ClosingPortalHostsLayer closeCoverOpacity={closeCoverOpacity} />
457
+ <ClosingPortalHostsLayer
458
+ closeCoverOpacity={closeCoverOpacity}
459
+ hostLayerId={hostLayerId}
460
+ isTopHostLayer={isTop}
461
+ />
431
462
  </View>
432
463
  </GestureDetector>
433
464
  );
@@ -0,0 +1,9 @@
1
+ import React, { useContext } from 'react';
2
+
3
+ /**
4
+ * The id of the `MessageOverlayHostLayer` rendered by the nearest `OverlayProvider`. Views that
5
+ * teleport into that layer use it to tell whether their provider currently owns the overlay.
6
+ */
7
+ export const OverlayHostLayerContext = React.createContext<string | undefined>(undefined);
8
+
9
+ export const useOverlayHostLayerId = () => useContext(OverlayHostLayerContext);
@@ -8,10 +8,12 @@ import { PortalProvider } from 'react-native-teleport';
8
8
 
9
9
  import { MessageOverlayHostLayer } from './MessageOverlayHostLayer';
10
10
  import { OverlayContext, OverlayProviderProps } from './OverlayContext';
11
+ import { OverlayHostLayerContext } from './OverlayHostLayerContext';
11
12
 
12
13
  import { OverlayA11yShield } from '../../components/Accessibility/OverlayA11yShield';
13
14
  import { ImageGallery } from '../../components/ImageGallery/ImageGallery';
14
15
  import { useStreami18n } from '../../hooks/useStreami18n';
16
+ import { createOverlayHostLayerId } from '../../state-store';
15
17
 
16
18
  import { AccessibilityProvider } from '../accessibilityContext/AccessibilityContext';
17
19
  import { ImageGalleryProvider } from '../imageGalleryContext/ImageGalleryContext';
@@ -55,6 +57,7 @@ export const OverlayProvider = (props: PropsWithChildren<OverlayProviderProps>)
55
57
  } = props;
56
58
 
57
59
  const [overlay, setOverlay] = useState(value?.overlay || 'none');
60
+ const [overlayHostLayerId] = useState(createOverlayHostLayerId);
58
61
 
59
62
  const overlayOpacity = useSharedValue(0);
60
63
 
@@ -110,13 +113,15 @@ export const OverlayProvider = (props: PropsWithChildren<OverlayProviderProps>)
110
113
  <ImageGalleryProvider value={imageGalleryProviderProps}>
111
114
  <ThemeProvider style={overlayContext.style}>
112
115
  <PortalProvider>
113
- {accessibility?.enabled ? (
114
- <OverlayA11yShield>{children}</OverlayA11yShield>
115
- ) : (
116
- children
117
- )}
118
- {overlay === 'gallery' && <ImageGallery overlayOpacity={overlayOpacity} />}
119
- <MessageOverlayHostLayer />
116
+ <OverlayHostLayerContext.Provider value={overlayHostLayerId}>
117
+ {accessibility?.enabled ? (
118
+ <OverlayA11yShield>{children}</OverlayA11yShield>
119
+ ) : (
120
+ children
121
+ )}
122
+ {overlay === 'gallery' && <ImageGallery overlayOpacity={overlayOpacity} />}
123
+ <MessageOverlayHostLayer hostLayerId={overlayHostLayerId} />
124
+ </OverlayHostLayerContext.Provider>
120
125
  </PortalProvider>
121
126
  </ThemeProvider>
122
127
  </ImageGalleryProvider>
@@ -115,4 +115,31 @@ describe('ClosingPortalHostsLayer', () => {
115
115
  expect(screen.getAllByTestId('closing-portal-host-slot-overlay-header')).toHaveLength(1);
116
116
  expect(screen.getAllByTestId('closing-portal-host-slot-overlay-composer')).toHaveLength(1);
117
117
  });
118
+
119
+ it('parks its hosts under names unique to its host layer while that layer is not the top one', () => {
120
+ const renderLayer = (isTopHostLayer: boolean) => (
121
+ <ClosingPortalHostsLayer
122
+ closeCoverOpacity={{ value: 0 } as SharedValue<number>}
123
+ hostLayerId='root-layer'
124
+ isTopHostLayer={isTopHostLayer}
125
+ />
126
+ );
127
+ const getHostsNamed = (name: string) =>
128
+ screen.UNSAFE_root.findAll(
129
+ (node) => typeof node.type === 'string' && node.props.name === name,
130
+ );
131
+ const { rerender } = render(renderLayer(false));
132
+
133
+ act(() => {
134
+ setClosingPortalLayout('overlay-composer', 'composer-entry', FIRST_RECT);
135
+ });
136
+
137
+ expect(getHostsNamed('overlay-composer')).toHaveLength(0);
138
+ expect(getHostsNamed('overlay-composer:root-layer')).toHaveLength(1);
139
+
140
+ rerender(renderLayer(true));
141
+
142
+ expect(getHostsNamed('overlay-composer')).toHaveLength(1);
143
+ expect(getHostsNamed('overlay-composer:root-layer')).toHaveLength(0);
144
+ });
118
145
  });
@@ -159,7 +159,7 @@ describe('MessageOverlayHostLayer', () => {
159
159
  const CustomBackground = () => <Text testID='custom-background'>Background</Text>;
160
160
  render(
161
161
  <WithComponents overrides={{ MessageOverlayBackground: CustomBackground }}>
162
- <MessageOverlayHostLayer />
162
+ <MessageOverlayHostLayer hostLayerId='layer' />
163
163
  </WithComponents>,
164
164
  );
165
165
 
@@ -197,7 +197,7 @@ describe('MessageOverlayHostLayer', () => {
197
197
 
198
198
  render(
199
199
  <WithComponents overrides={{ MessageOverlayBackground: NoopBackground }}>
200
- <MessageOverlayHostLayer />
200
+ <MessageOverlayHostLayer hostLayerId='layer' />
201
201
  </WithComponents>,
202
202
  );
203
203
 
@@ -239,7 +239,7 @@ describe('MessageOverlayHostLayer', () => {
239
239
  it('positions and translates the top, message, and bottom hosts using the registered rects', () => {
240
240
  const renderTree = () => (
241
241
  <WithComponents overrides={{ MessageOverlayBackground: NoopBackground }}>
242
- <MessageOverlayHostLayer />
242
+ <MessageOverlayHostLayer hostLayerId='layer' />
243
243
  </WithComponents>
244
244
  );
245
245
  const { rerender } = render(renderTree());
@@ -289,7 +289,7 @@ describe('MessageOverlayHostLayer', () => {
289
289
  it('keeps the hosts mounted but resets their geometry after finalizeCloseOverlay', () => {
290
290
  const renderTree = () => (
291
291
  <WithComponents overrides={{ MessageOverlayBackground: NoopBackground }}>
292
- <MessageOverlayHostLayer />
292
+ <MessageOverlayHostLayer hostLayerId='layer' />
293
293
  </WithComponents>
294
294
  );
295
295
  const { rerender } = render(renderTree());
@@ -341,7 +341,7 @@ describe('MessageOverlayHostLayer', () => {
341
341
 
342
342
  const renderTree = () => (
343
343
  <WithComponents overrides={{ MessageOverlayBackground: NoopBackground }}>
344
- <MessageOverlayHostLayer />
344
+ <MessageOverlayHostLayer hostLayerId='layer' />
345
345
  </WithComponents>
346
346
  );
347
347
  const { rerender } = render(renderTree());
@@ -396,7 +396,7 @@ describe('MessageOverlayHostLayer', () => {
396
396
  MessageOverlayBackground: NoopBackground,
397
397
  }}
398
398
  >
399
- <MessageOverlayHostLayer />
399
+ <MessageOverlayHostLayer hostLayerId='layer' />
400
400
  </WithComponents>
401
401
  );
402
402
  const { rerender } = render(renderTree());
@@ -433,4 +433,82 @@ describe('MessageOverlayHostLayer', () => {
433
433
  width: BOTTOM_RECT.w,
434
434
  });
435
435
  });
436
+
437
+ it('lets the most recently mounted host layer own the overlay and hands it back once that layer unmounts', () => {
438
+ const renderTree = (withNestedLayer: boolean) => (
439
+ <WithComponents overrides={{ MessageOverlayBackground: NoopBackground }}>
440
+ <MessageOverlayHostLayer hostLayerId='root-layer' />
441
+ {withNestedLayer ? <MessageOverlayHostLayer hostLayerId='nested-layer' /> : null}
442
+ </WithComponents>
443
+ );
444
+ const getHostsNamed = (name: string) =>
445
+ screen.UNSAFE_root.findAll(
446
+ (node) => typeof node.type === 'string' && node.props.name === name,
447
+ );
448
+ const { rerender } = render(renderTree(true));
449
+
450
+ // The nested layer owns the shared host names, the root layer parks its own.
451
+ expect(getHostsNamed('bottom-item')).toHaveLength(1);
452
+ expect(getHostsNamed('bottom-item:root-layer')).toHaveLength(1);
453
+
454
+ act(() => {
455
+ setOverlayTopH(TOP_RECT);
456
+ setOverlayMessageH(MESSAGE_RECT);
457
+ setOverlayBottomH(BOTTOM_RECT);
458
+ openOverlay('message-1');
459
+ });
460
+
461
+ rerender(renderTree(true));
462
+
463
+ expect(screen.getAllByTestId('message-overlay-backdrop')).toHaveLength(1);
464
+ const [rootBottomSlot, nestedBottomSlot] = screen.getAllByTestId('message-overlay-bottom');
465
+ expect(StyleSheet.flatten(nestedBottomSlot.props.style)).toMatchObject({
466
+ height: BOTTOM_RECT.h,
467
+ width: BOTTOM_RECT.w,
468
+ });
469
+ expect(StyleSheet.flatten(rootBottomSlot.props.style)).toMatchObject({ height: 0 });
470
+
471
+ act(() => {
472
+ finalizeCloseOverlay();
473
+ });
474
+
475
+ rerender(renderTree(false));
476
+
477
+ expect(getHostsNamed('bottom-item')).toHaveLength(1);
478
+ expect(getHostsNamed('bottom-item:root-layer')).toHaveLength(0);
479
+
480
+ act(() => {
481
+ setOverlayTopH(TOP_RECT);
482
+ setOverlayMessageH(MESSAGE_RECT);
483
+ setOverlayBottomH(BOTTOM_RECT);
484
+ openOverlay('message-2');
485
+ });
486
+
487
+ rerender(renderTree(false));
488
+
489
+ expect(screen.getAllByTestId('message-overlay-backdrop')).toHaveLength(1);
490
+ expect(
491
+ StyleSheet.flatten(screen.getByTestId('message-overlay-bottom').props.style),
492
+ ).toMatchObject({
493
+ height: BOTTOM_RECT.h,
494
+ width: BOTTOM_RECT.w,
495
+ });
496
+ });
497
+
498
+ it('renders a MessageActions override only in the top host layer', () => {
499
+ const renderTree = () => (
500
+ <WithComponents
501
+ overrides={{
502
+ MessageActions: CustomMessageActions,
503
+ MessageOverlayBackground: NoopBackground,
504
+ }}
505
+ >
506
+ <MessageOverlayHostLayer hostLayerId='root-layer' />
507
+ <MessageOverlayHostLayer hostLayerId='nested-layer' />
508
+ </WithComponents>
509
+ );
510
+ render(renderTree());
511
+
512
+ expect(screen.getAllByTestId('custom-message-actions')).toHaveLength(1);
513
+ });
436
514
  });
@@ -9,10 +9,15 @@ import {
9
9
  finalizeCloseOverlay,
10
10
  openOverlay,
11
11
  overlayStore,
12
+ registerOverlayHostLayer,
13
+ registerOverlaySharedValueController,
12
14
  scheduleActionOnClose,
13
15
  setClosingPortalLayout,
16
+ setOverlayBottomH,
17
+ setOverlayMessageH,
14
18
  useClosingPortalHostBlacklist,
15
19
  useClosingPortalHostBlacklistState,
20
+ useIsTopOverlayHostLayer,
16
21
  useShouldTeleportToClosingPortal,
17
22
  } from '../message-overlay-store';
18
23
 
@@ -308,3 +313,95 @@ describe('message overlay store portal hooks', () => {
308
313
  allowed.unmount();
309
314
  });
310
315
  });
316
+
317
+ describe('message overlay host layer stack', () => {
318
+ const createController = () => ({
319
+ incrementCloseCorrectionY: jest.fn(),
320
+ reset: jest.fn(),
321
+ resetCloseCorrectionY: jest.fn(),
322
+ setBottomH: jest.fn(),
323
+ setMessageH: jest.fn(),
324
+ setTopH: jest.fn(),
325
+ });
326
+
327
+ afterEach(() => {
328
+ cleanup();
329
+ });
330
+
331
+ it('routes the overlay geometry to the most recently registered controller and hands it back once that one unregisters', () => {
332
+ const rootController = createController();
333
+ const nestedController = createController();
334
+ const unregisterRoot = registerOverlaySharedValueController(rootController);
335
+ const unregisterNested = registerOverlaySharedValueController(nestedController);
336
+
337
+ setOverlayMessageH(BASE_RECT);
338
+
339
+ expect(nestedController.setMessageH).toHaveBeenCalledWith(BASE_RECT);
340
+ expect(rootController.setMessageH).not.toHaveBeenCalled();
341
+
342
+ unregisterNested();
343
+ setOverlayMessageH(BASE_RECT);
344
+
345
+ expect(rootController.setMessageH).toHaveBeenCalledWith(BASE_RECT);
346
+ expect(nestedController.setMessageH).toHaveBeenCalledTimes(1);
347
+
348
+ unregisterRoot();
349
+ });
350
+
351
+ it('keeps routing to the top controller when a controller below it unregisters', () => {
352
+ const rootController = createController();
353
+ const nestedController = createController();
354
+ const unregisterRoot = registerOverlaySharedValueController(rootController);
355
+ const unregisterNested = registerOverlaySharedValueController(nestedController);
356
+
357
+ unregisterRoot();
358
+ setOverlayBottomH(BASE_RECT);
359
+
360
+ expect(nestedController.setBottomH).toHaveBeenCalledWith(BASE_RECT);
361
+ expect(rootController.setBottomH).not.toHaveBeenCalled();
362
+
363
+ unregisterNested();
364
+ });
365
+
366
+ it('reports only the most recently registered host layer as the top one', () => {
367
+ const root = renderHook(() => useIsTopOverlayHostLayer('root-layer'));
368
+ const nested = renderHook(() => useIsTopOverlayHostLayer('nested-layer'));
369
+ const withoutLayer = renderHook(() => useIsTopOverlayHostLayer(undefined));
370
+
371
+ expect(root.result.current).toBe(true);
372
+ expect(nested.result.current).toBe(true);
373
+
374
+ let unregisterRoot = () => {};
375
+ let unregisterNested = () => {};
376
+
377
+ act(() => {
378
+ unregisterRoot = registerOverlayHostLayer('root-layer');
379
+ });
380
+
381
+ expect(root.result.current).toBe(true);
382
+ expect(nested.result.current).toBe(false);
383
+
384
+ act(() => {
385
+ unregisterNested = registerOverlayHostLayer('nested-layer');
386
+ });
387
+
388
+ expect(root.result.current).toBe(false);
389
+ expect(nested.result.current).toBe(true);
390
+ expect(withoutLayer.result.current).toBe(true);
391
+
392
+ act(() => {
393
+ unregisterNested();
394
+ });
395
+
396
+ expect(root.result.current).toBe(true);
397
+ expect(nested.result.current).toBe(false);
398
+
399
+ act(() => {
400
+ unregisterRoot();
401
+ });
402
+
403
+ root.unmount();
404
+ nested.unmount();
405
+ withoutLayer.unmount();
406
+ });
407
+ });