diff --git a/examples/SampleApp/src/screens/ThreadScreen.tsx b/examples/SampleApp/src/screens/ThreadScreen.tsx index 23b406007a..715ff04515 100644 --- a/examples/SampleApp/src/screens/ThreadScreen.tsx +++ b/examples/SampleApp/src/screens/ThreadScreen.tsx @@ -11,6 +11,7 @@ import { useTheme, useTranslationContext, useTypingString, + PortalWhileClosingView, } from 'stream-chat-react-native'; import { useStateStore } from 'stream-chat-react-native'; @@ -161,7 +162,12 @@ export const ThreadScreen: React.FC = ({ onAlsoSentToChannelHeaderPress={onAlsoSentToChannelHeaderPress} messageId={targetedMessageIdFromParams} > - + + + { - const { closing } = useOverlayController(); const containerRef = useRef(null); + const registrationIdRef = useRef(null); const placeholderLayout = useSharedValue({ h: 0, w: 0 }); const insets = useSafeAreaInsets(); + if (!registrationIdRef.current) { + registrationIdRef.current = createClosingPortalLayoutRegistrationId(); + } + + const registrationId = registrationIdRef.current; + const shouldTeleport = useShouldTeleportToClosingPortal(portalHostName, registrationId); + const syncPortalLayout = useStableCallback(() => { containerRef.current?.measureInWindow((x, y, width, height) => { const absolute = { @@ -83,7 +92,7 @@ export const PortalWhileClosingView = ({ placeholderLayout.value = { h: height, w: width }; - setClosingPortalLayout(portalHostName, { + setClosingPortalLayout(portalHostName, registrationId, { ...absolute, h: height, w: width, @@ -91,6 +100,12 @@ export const PortalWhileClosingView = ({ }); }); + useEffect(() => { + return () => { + clearClosingPortalLayout(portalHostName, registrationId); + }; + }, [portalHostName, registrationId]); + useEffect(() => { // Measure once after mount and layout settle. requestAnimationFrame(() => { @@ -100,14 +115,6 @@ export const PortalWhileClosingView = ({ }); }, [insets.top, portalHostName, syncPortalLayout]); - const unregisterPortalHost = useStableCallback(() => clearClosingPortalLayout(portalHostName)); - - useEffect(() => { - return () => { - unregisterPortalHost(); - }; - }, [unregisterPortalHost]); - const placeholderStyle = useAnimatedStyle(() => ({ height: placeholderLayout.value.h, width: placeholderLayout.value.w > 0 ? placeholderLayout.value.w : '100%', @@ -115,12 +122,18 @@ export const PortalWhileClosingView = ({ return ( <> - + {children} - {closing ? : null} + {shouldTeleport ? ( + + ) : null} ); }; diff --git a/package/src/components/UIComponents/__tests__/PortalWhileClosingView.test.tsx b/package/src/components/UIComponents/__tests__/PortalWhileClosingView.test.tsx new file mode 100644 index 0000000000..6c42900f5e --- /dev/null +++ b/package/src/components/UIComponents/__tests__/PortalWhileClosingView.test.tsx @@ -0,0 +1,173 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { act, cleanup, render, screen } from '@testing-library/react-native'; + +import * as stateStore from '../../../state-store'; +import { PortalWhileClosingView } from '../PortalWhileClosingView'; + +jest.mock('../../../state-store', () => { + const actual = jest.requireActual('../../../state-store'); + const createClosingPortalLayoutRegistrationId = jest.fn(() => 'registration-1'); + + return new Proxy(actual, { + get(target, prop, receiver) { + if (prop === 'createClosingPortalLayoutRegistrationId') { + return createClosingPortalLayoutRegistrationId; + } + + return Reflect.get(target, prop, receiver); + }, + }); +}); + +const BASE_RECT = { h: 48, w: 120, x: 12, y: 24 }; + +const flushAnimationFrameQueue = () => { + act(() => { + jest.runAllTimers(); + }); +}; + +const TeleportStateProbe = ({ + hostName, + id, + testID, +}: { + hostName: string; + id: string; + testID: string; +}) => { + const shouldTeleport = stateStore.useShouldTeleportToClosingPortal(hostName, id); + + return {shouldTeleport ? 'true' : 'false'}; +}; + +const BlacklistRegistrar = ({ hostNames }: { hostNames: string[] }) => { + stateStore.useClosingPortalHostBlacklist(hostNames); + return null; +}; + +describe('PortalWhileClosingView', () => { + beforeEach(() => { + jest.useFakeTimers(); + + act(() => { + stateStore.finalizeCloseOverlay(); + stateStore.overlayStore.next({ + closing: false, + closingPortalHostBlacklist: [], + id: undefined, + }); + }); + }); + + afterEach(() => { + cleanup(); + + act(() => { + stateStore.clearClosingPortalLayout('overlay-composer', 'registration-1'); + stateStore.finalizeCloseOverlay(); + stateStore.overlayStore.next({ + closing: false, + closingPortalHostBlacklist: [], + id: undefined, + }); + }); + + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('uses the real store to teleport once the overlay is closing and this host is active', () => { + render( + <> + + Composer + + + , + ); + + act(() => { + stateStore.setClosingPortalLayout('overlay-composer', 'registration-1', BASE_RECT); + }); + + expect(screen.getByTestId('teleport-state')).toHaveTextContent('false'); + expect(screen.queryByTestId('portal-while-closing-placeholder-composer-portal')).toBeNull(); + + act(() => { + stateStore.openOverlay('message-1'); + stateStore.closeOverlay(); + }); + flushAnimationFrameQueue(); + + expect(screen.getByTestId('teleport-state')).toHaveTextContent('true'); + expect(screen.getByTestId('portal-while-closing-placeholder-composer-portal')).toBeTruthy(); + }); + + it('keeps the portal local when the host is blacklisted', () => { + render( + <> + + + Composer + + + , + ); + + act(() => { + stateStore.setClosingPortalLayout('overlay-composer', 'registration-1', BASE_RECT); + stateStore.openOverlay('message-1'); + stateStore.closeOverlay(); + }); + flushAnimationFrameQueue(); + + expect(screen.getByTestId('teleport-state')).toHaveTextContent('false'); + expect(screen.queryByTestId('portal-while-closing-placeholder-composer-portal')).toBeNull(); + }); + + it('clears its registration from the real store when it unmounts', () => { + const { rerender } = render( + <> + + Composer + + + , + ); + + act(() => { + stateStore.setClosingPortalLayout('overlay-composer', 'registration-1', BASE_RECT); + stateStore.openOverlay('message-1'); + stateStore.closeOverlay(); + }); + flushAnimationFrameQueue(); + + expect(screen.getByTestId('teleport-state')).toHaveTextContent('true'); + + rerender( + , + ); + + expect(screen.getByTestId('teleport-state')).toHaveTextContent('false'); + }); +}); diff --git a/package/src/contexts/overlayContext/ClosingPortalHostsLayer.tsx b/package/src/contexts/overlayContext/ClosingPortalHostsLayer.tsx index 78fd83f6d2..787a0e0d2d 100644 --- a/package/src/contexts/overlayContext/ClosingPortalHostsLayer.tsx +++ b/package/src/contexts/overlayContext/ClosingPortalHostsLayer.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { StyleSheet } from 'react-native'; import Animated, { SharedValue, useAnimatedStyle } from 'react-native-reanimated'; import { PortalHost } from 'react-native-teleport'; @@ -31,7 +31,11 @@ const ClosingPortalHostSlot = ({ }); return ( - + ); @@ -42,16 +46,31 @@ type ClosingPortalHostsLayerProps = { }; export const ClosingPortalHostsLayer = ({ closeCoverOpacity }: ClosingPortalHostsLayerProps) => { - const closingPortalLayouts = useClosingPortalLayouts(); + const closingPortalLayoutStacks = useClosingPortalLayouts(); + const closingPortalHosts = useMemo(() => { + const topHosts: Array<{ + hostName: string; + layout: ClosingPortalLayoutEntry['layout']; + }> = []; + + Object.entries(closingPortalLayoutStacks).forEach(([hostName, entries]) => { + const topEntry = entries[entries.length - 1]; + if (topEntry) { + topHosts.push({ hostName, layout: topEntry.layout }); + } + }); + + return topHosts; + }, [closingPortalLayoutStacks]); return ( <> - {Object.entries(closingPortalLayouts).map(([hostName, entry]) => ( + {closingPortalHosts.map(({ hostName, layout }) => ( ))} diff --git a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx index 82cab70c9c..fb7474edf6 100644 --- a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx +++ b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx @@ -264,18 +264,29 @@ export const MessageOverlayHostLayer = ({ BackgroundComponent }: MessageOverlayH {isActive ? ( - + ) : null} - + - + - + diff --git a/package/src/contexts/overlayContext/__tests__/ClosingPortalHostsLayer.test.tsx b/package/src/contexts/overlayContext/__tests__/ClosingPortalHostsLayer.test.tsx new file mode 100644 index 0000000000..faa4874f0a --- /dev/null +++ b/package/src/contexts/overlayContext/__tests__/ClosingPortalHostsLayer.test.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; +import type { SharedValue } from 'react-native-reanimated'; + +import { act, cleanup, render, screen } from '@testing-library/react-native'; + +import { clearClosingPortalLayout, setClosingPortalLayout } from '../../../state-store'; +import { ClosingPortalHostsLayer } from '../ClosingPortalHostsLayer'; + +jest.mock('react-native-reanimated', () => { + const actual = jest.requireActual('react-native-reanimated/mock'); + const { View } = require('react-native'); + + return { + ...actual, + Animated: { + ...actual.default, + View, + }, + default: { + ...actual.default, + View, + }, + makeMutable: (value: unknown) => ({ value }), + useAnimatedStyle: (updater: () => unknown) => updater(), + }; +}); + +const FIRST_RECT = { h: 40, w: 100, x: 10, y: 20 }; +const SECOND_RECT = { h: 52, w: 140, x: 30, y: 45 }; + +describe('ClosingPortalHostsLayer', () => { + beforeEach(() => { + cleanup(); + }); + + afterEach(() => { + act(() => { + clearClosingPortalLayout('overlay-header', 'first-entry'); + clearClosingPortalLayout('overlay-header', 'second-entry'); + clearClosingPortalLayout('overlay-composer', 'composer-entry'); + }); + cleanup(); + }); + + it('renders the geometry for the top-most registration of a host and falls back when it is removed', () => { + render(} />); + + act(() => { + setClosingPortalLayout('overlay-header', 'first-entry', FIRST_RECT); + }); + + expect( + StyleSheet.flatten(screen.getByTestId('closing-portal-host-slot-overlay-header').props.style), + ).toMatchObject({ + height: FIRST_RECT.h, + left: FIRST_RECT.x, + opacity: 0.5, + position: 'absolute', + top: FIRST_RECT.y, + width: FIRST_RECT.w, + }); + + act(() => { + setClosingPortalLayout('overlay-header', 'second-entry', SECOND_RECT); + }); + + expect( + StyleSheet.flatten(screen.getByTestId('closing-portal-host-slot-overlay-header').props.style), + ).toMatchObject({ + height: SECOND_RECT.h, + left: SECOND_RECT.x, + opacity: 0.5, + position: 'absolute', + top: SECOND_RECT.y, + width: SECOND_RECT.w, + }); + + act(() => { + clearClosingPortalLayout('overlay-header', 'second-entry'); + }); + + expect( + StyleSheet.flatten(screen.getByTestId('closing-portal-host-slot-overlay-header').props.style), + ).toMatchObject({ + height: FIRST_RECT.h, + left: FIRST_RECT.x, + opacity: 0.5, + position: 'absolute', + top: FIRST_RECT.y, + width: FIRST_RECT.w, + }); + + act(() => { + clearClosingPortalLayout('overlay-header', 'first-entry'); + }); + + expect(screen.queryByTestId('closing-portal-host-slot-overlay-header')).toBeNull(); + }); + + it('renders one closing host slot per host name even when multiple entries are registered', () => { + render(} />); + + act(() => { + setClosingPortalLayout('overlay-header', 'first-entry', FIRST_RECT); + setClosingPortalLayout('overlay-header', 'second-entry', SECOND_RECT); + setClosingPortalLayout('overlay-composer', 'composer-entry', { + h: 60, + w: 160, + x: 5, + y: 200, + }); + }); + + expect(screen.getAllByTestId('closing-portal-host-slot-overlay-header')).toHaveLength(1); + expect(screen.getAllByTestId('closing-portal-host-slot-overlay-composer')).toHaveLength(1); + }); +}); diff --git a/package/src/contexts/overlayContext/__tests__/MessageOverlayHostLayer.test.tsx b/package/src/contexts/overlayContext/__tests__/MessageOverlayHostLayer.test.tsx new file mode 100644 index 0000000000..d422a7f64c --- /dev/null +++ b/package/src/contexts/overlayContext/__tests__/MessageOverlayHostLayer.test.tsx @@ -0,0 +1,240 @@ +import React from 'react'; +import { StyleSheet, Text } from 'react-native'; + +import * as SafeAreaContext from 'react-native-safe-area-context'; + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react-native'; + +import { + finalizeCloseOverlay, + openOverlay, + overlayStore, + setOverlayBottomH, + setOverlayMessageH, + setOverlayTopH, +} from '../../../state-store'; +import { MessageOverlayHostLayer } from '../MessageOverlayHostLayer'; + +jest.mock('react-native', () => { + const actual = jest.requireActual('react-native'); + + return new Proxy(actual, { + get(target, prop, receiver) { + if (prop === 'useWindowDimensions') { + return () => ({ fontScale: 1, height: 200, scale: 1, width: 320 }); + } + + return Reflect.get(target, prop, receiver); + }, + }); +}); + +jest.mock('react-native-reanimated', () => { + const React = require('react'); + const actual = jest.requireActual('react-native-reanimated/mock'); + const { View } = require('react-native'); + + const useStableSharedValue = (init: unknown) => { + const ref = React.useRef<{ + value: unknown; + }>(); + + if (!ref.current) { + const value = { value: init }; + ref.current = new Proxy(value, { + get(target, prop) { + if (prop === 'value') { + return target.value; + } + + return undefined; + }, + set(target, prop, nextValue) { + if (prop === 'value') { + target.value = nextValue; + return true; + } + + return false; + }, + }); + } + + return ref.current; + }; + + return { + ...actual, + Animated: { + ...actual.default, + View, + }, + default: { + ...actual.default, + View, + }, + clamp: (value: number, min: number, max: number) => Math.min(Math.max(value, min), max), + runOnJS: (fn: (...args: unknown[]) => unknown) => fn, + useAnimatedStyle: (updater: () => unknown) => updater(), + useDerivedValue: (updater: () => unknown) => ({ value: updater() }), + useSharedValue: useStableSharedValue, + withSpring: (value: unknown) => value, + }; +}); + +const TOP_RECT = { h: 20, w: 90, x: 5, y: 0 }; +const MESSAGE_RECT = { h: 50, w: 180, x: 10, y: 0 }; +const BOTTOM_RECT = { h: 30, w: 140, x: 20, y: 100 }; +const NoopBackground = () => null; + +const flushAnimationFrameQueue = () => { + act(() => { + jest.runAllTimers(); + }); +}; + +describe('MessageOverlayHostLayer', () => { + let useSafeAreaInsetsSpy: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + useSafeAreaInsetsSpy = jest.spyOn(SafeAreaContext, 'useSafeAreaInsets').mockReturnValue({ + bottom: 15, + left: 0, + right: 0, + top: 10, + }); + + act(() => { + finalizeCloseOverlay(); + overlayStore.next({ + closing: false, + closingPortalHostBlacklist: [], + id: undefined, + }); + }); + }); + + afterEach(() => { + cleanup(); + + act(() => { + finalizeCloseOverlay(); + overlayStore.next({ + closing: false, + closingPortalHostBlacklist: [], + id: undefined, + }); + }); + + useSafeAreaInsetsSpy.mockRestore(); + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('renders the custom background only while active and pressing the backdrop starts closing', () => { + const CustomBackground = () => Background; + render(); + + expect(screen.queryByTestId('custom-background')).toBeNull(); + expect(screen.queryByTestId('message-overlay-backdrop')).toBeNull(); + + act(() => { + openOverlay('message-1'); + }); + + expect(screen.getByTestId('custom-background')).toBeTruthy(); + expect(screen.getByTestId('message-overlay-backdrop')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('message-overlay-backdrop')); + flushAnimationFrameQueue(); + + expect(overlayStore.getLatestValue().closing).toBe(true); + }); + + it('positions and translates the top, message, and bottom hosts using the registered rects', () => { + const { rerender } = render(); + + act(() => {}); + + act(() => { + setOverlayTopH(TOP_RECT); + setOverlayMessageH(MESSAGE_RECT); + setOverlayBottomH(BOTTOM_RECT); + openOverlay('message-1'); + }); + + rerender(); + + const topSlot = screen.getByTestId('message-overlay-top'); + const messageSlot = screen.getByTestId('message-overlay-message'); + const bottomSlot = screen.getByTestId('message-overlay-bottom'); + + expect(StyleSheet.flatten(topSlot.props.style)).toMatchObject({ + height: TOP_RECT.h, + left: TOP_RECT.x, + position: 'absolute', + top: TOP_RECT.y, + transform: [{ scale: 1 }, { translateY: 38 }], + width: TOP_RECT.w, + }); + expect(StyleSheet.flatten(messageSlot.props.style)).toMatchObject({ + height: MESSAGE_RECT.h, + left: MESSAGE_RECT.x, + position: 'absolute', + top: MESSAGE_RECT.y, + transform: [{ translateY: 38 }], + width: MESSAGE_RECT.w, + }); + expect(StyleSheet.flatten(bottomSlot.props.style)).toMatchObject({ + height: BOTTOM_RECT.h, + left: BOTTOM_RECT.x, + position: 'absolute', + top: BOTTOM_RECT.y, + transform: [{ scale: 1 }, { translateY: -12 }], + width: BOTTOM_RECT.w, + }); + }); + + it('resets host geometry after finalizeCloseOverlay clears the registered rects', () => { + const { rerender } = render(); + + act(() => {}); + + act(() => { + setOverlayTopH(TOP_RECT); + setOverlayMessageH(MESSAGE_RECT); + setOverlayBottomH(BOTTOM_RECT); + openOverlay('message-1'); + }); + + rerender(); + + expect( + StyleSheet.flatten(screen.getByTestId('message-overlay-message').props.style), + ).toMatchObject({ + height: MESSAGE_RECT.h, + width: MESSAGE_RECT.w, + }); + + act(() => { + finalizeCloseOverlay(); + }); + + rerender(); + + expect(StyleSheet.flatten(screen.getByTestId('message-overlay-top').props.style)).toMatchObject( + { + height: 0, + }, + ); + expect( + StyleSheet.flatten(screen.getByTestId('message-overlay-message').props.style), + ).toMatchObject({ height: 0 }); + expect( + StyleSheet.flatten(screen.getByTestId('message-overlay-bottom').props.style), + ).toMatchObject({ + height: 0, + }); + }); +}); diff --git a/package/src/state-store/__tests__/message-overlay-store.test.tsx b/package/src/state-store/__tests__/message-overlay-store.test.tsx new file mode 100644 index 0000000000..af6aa4ac11 --- /dev/null +++ b/package/src/state-store/__tests__/message-overlay-store.test.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { act, cleanup, render, renderHook, screen } from '@testing-library/react-native'; + +import { + clearClosingPortalLayout, + closeOverlay, + finalizeCloseOverlay, + openOverlay, + overlayStore, + setClosingPortalLayout, + useClosingPortalHostBlacklist, + useClosingPortalHostBlacklistState, + useShouldTeleportToClosingPortal, +} from '../message-overlay-store'; + +const BASE_RECT = { h: 40, w: 100, x: 10, y: 20 }; + +type RegisteredLayout = { + hostName: string; + id: string; +}; + +const flushAnimationFrameQueue = () => { + act(() => { + jest.runAllTimers(); + }); +}; + +const BlacklistRegistrar = ({ + enabled = true, + hostNames, +}: { + enabled?: boolean; + hostNames: string[]; +}) => { + useClosingPortalHostBlacklist(hostNames, enabled); + return null; +}; + +const BlacklistProbe = () => { + const hostNames = useClosingPortalHostBlacklistState(); + + return {hostNames.join(',') || 'empty'}; +}; + +describe('message overlay store portal hooks', () => { + let registeredLayouts: RegisteredLayout[] = []; + + const rememberLayout = (hostName: string, id: string, layout = BASE_RECT) => { + registeredLayouts.push({ hostName, id }); + act(() => { + setClosingPortalLayout(hostName, id, layout); + }); + }; + + beforeEach(() => { + jest.useFakeTimers(); + registeredLayouts = []; + + act(() => { + finalizeCloseOverlay(); + overlayStore.next({ + closing: false, + closingPortalHostBlacklist: [], + id: undefined, + }); + }); + }); + + afterEach(() => { + cleanup(); + + act(() => { + registeredLayouts.forEach(({ hostName, id }) => { + clearClosingPortalLayout(hostName, id); + }); + finalizeCloseOverlay(); + overlayStore.next({ + closing: false, + closingPortalHostBlacklist: [], + id: undefined, + }); + }); + + registeredLayouts = []; + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('returns true only for the top-most registration when the overlay is closing', () => { + const first = renderHook(() => useShouldTeleportToClosingPortal('overlay-composer', 'first')); + const second = renderHook(() => useShouldTeleportToClosingPortal('overlay-composer', 'second')); + + rememberLayout('overlay-composer', 'first'); + + expect(first.result.current).toBe(false); + expect(second.result.current).toBe(false); + + act(() => { + openOverlay('message-1'); + closeOverlay(); + }); + flushAnimationFrameQueue(); + + expect(first.result.current).toBe(true); + expect(second.result.current).toBe(false); + + rememberLayout('overlay-composer', 'second', { ...BASE_RECT, x: 30, y: 50 }); + + expect(first.result.current).toBe(false); + expect(second.result.current).toBe(true); + + act(() => { + clearClosingPortalLayout('overlay-composer', 'second'); + }); + + expect(first.result.current).toBe(true); + expect(second.result.current).toBe(false); + + first.unmount(); + second.unmount(); + }); + + it('restores the previous blacklist when the top blacklist registration disappears', () => { + const { rerender } = render( + <> + + + , + ); + + expect(screen.getByTestId('blacklist-state')).toHaveTextContent('overlay-header'); + + rerender( + <> + + + + , + ); + + expect(screen.getByTestId('blacklist-state')).toHaveTextContent('overlay-composer'); + + rerender( + <> + + + + , + ); + + expect(screen.getByTestId('blacklist-state')).toHaveTextContent('overlay-header'); + + rerender(); + + expect(screen.getByTestId('blacklist-state')).toHaveTextContent('empty'); + }); + + it('prevents teleporting blacklisted hosts even when they are top of stack and closing', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + <> + + {children} + + ); + + const blocked = renderHook( + () => useShouldTeleportToClosingPortal('overlay-composer', 'blocked'), + { wrapper }, + ); + const allowed = renderHook( + () => useShouldTeleportToClosingPortal('overlay-header', 'allowed'), + { + wrapper, + }, + ); + + rememberLayout('overlay-composer', 'blocked'); + rememberLayout('overlay-header', 'allowed', { ...BASE_RECT, x: 90, y: 120 }); + + act(() => { + openOverlay('message-1'); + closeOverlay(); + }); + flushAnimationFrameQueue(); + + expect(blocked.result.current).toBe(false); + expect(allowed.result.current).toBe(true); + + blocked.unmount(); + allowed.unmount(); + }); +}); diff --git a/package/src/state-store/message-overlay-store.ts b/package/src/state-store/message-overlay-store.ts index 890611d535..47411b3c53 100644 --- a/package/src/state-store/message-overlay-store.ts +++ b/package/src/state-store/message-overlay-store.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { makeMutable, type SharedValue } from 'react-native-reanimated'; @@ -8,19 +8,22 @@ import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { useStateStore } from '../hooks'; type OverlayState = { + closingPortalHostBlacklist: string[]; id: string | undefined; closing: boolean; }; export type Rect = { x: number; y: number; w: number; h: number } | undefined; export type ClosingPortalLayoutEntry = { + id: string; layout: SharedValue; }; type ClosingPortalLayoutsState = { - layouts: Record; + layouts: Record; }; const DefaultState = { + closingPortalHostBlacklist: [], closing: false, id: undefined, }; @@ -28,6 +31,10 @@ const DefaultClosingPortalLayoutsState: ClosingPortalLayoutsState = { layouts: {}, }; +let closingPortalLayoutRegistrationCounter = 0; +let closingPortalHostBlacklistRegistrationCounter = 0; +let closingPortalHostBlacklistStack: Array<{ hostNames: string[]; id: string }> = []; + type OverlaySharedValueController = { incrementCloseCorrectionY: (deltaY: number) => void; resetCloseCorrectionY: () => void; @@ -66,7 +73,11 @@ export const bumpOverlayLayoutRevision = (closeCorrectionDeltaY = 0) => { export const openOverlay = (id: string) => { sharedValueController?.resetCloseCorrectionY(); - overlayStore.partialNext({ closing: false, id }); + overlayStore.partialNext({ + closing: false, + closingPortalHostBlacklist: getCurrentClosingPortalHostBlacklist(), + id, + }); }; export const closeOverlay = () => { @@ -87,7 +98,10 @@ export const scheduleActionOnClose = (action: () => void | Promise) => { }; export const finalizeCloseOverlay = () => { - overlayStore.partialNext(DefaultState); + overlayStore.next({ + ...DefaultState, + closingPortalHostBlacklist: getCurrentClosingPortalHostBlacklist(), + }); sharedValueController?.reset(); }; @@ -96,9 +110,50 @@ const closingPortalLayoutsStore = new StateStore( DefaultClosingPortalLayoutsState, ); -export const setClosingPortalLayout = (hostName: string, layout: Rect) => { +export const createClosingPortalLayoutRegistrationId = () => + `closing-portal-layout-${closingPortalLayoutRegistrationCounter++}`; + +const getCurrentClosingPortalHostBlacklist = () => + closingPortalHostBlacklistStack[closingPortalHostBlacklistStack.length - 1]?.hostNames ?? []; + +const syncClosingPortalHostBlacklist = () => { + overlayStore.partialNext({ + closingPortalHostBlacklist: getCurrentClosingPortalHostBlacklist(), + }); +}; + +const createClosingPortalHostBlacklistRegistrationId = () => + `closing-portal-host-blacklist-${closingPortalHostBlacklistRegistrationCounter++}`; + +const setClosingPortalHostBlacklist = (id: string, hostNames: string[]) => { + const existingEntryIndex = closingPortalHostBlacklistStack.findIndex((entry) => entry.id === id); + + if (existingEntryIndex === -1) { + closingPortalHostBlacklistStack = [...closingPortalHostBlacklistStack, { hostNames, id }]; + } else { + closingPortalHostBlacklistStack = closingPortalHostBlacklistStack.map((entry, index) => + index === existingEntryIndex ? { ...entry, hostNames } : entry, + ); + } + + syncClosingPortalHostBlacklist(); +}; + +const clearClosingPortalHostBlacklist = (id: string) => { + const nextBlacklistStack = closingPortalHostBlacklistStack.filter((entry) => entry.id !== id); + + if (nextBlacklistStack.length === closingPortalHostBlacklistStack.length) { + return; + } + + closingPortalHostBlacklistStack = nextBlacklistStack; + syncClosingPortalHostBlacklist(); +}; + +export const setClosingPortalLayout = (hostName: string, id: string, layout: Rect) => { const { layouts } = closingPortalLayoutsStore.getLatestValue(); - const existingEntry = layouts[hostName]; + const hostEntries = layouts[hostName] ?? []; + const existingEntry = hostEntries.find((entry) => entry.id === id); if (existingEntry) { existingEntry.layout.value = layout; @@ -110,17 +165,28 @@ export const setClosingPortalLayout = (hostName: string, layout: Rect) => { closingPortalLayoutsStore.next({ layouts: { ...layouts, - [hostName]: { - layout: makeMutable(layout), - }, + [hostName]: [...hostEntries, { id, layout: makeMutable(layout) }], }, }); }; -export const clearClosingPortalLayout = (hostName: string) => { +export const clearClosingPortalLayout = (hostName: string, id: string) => { const { layouts } = closingPortalLayoutsStore.getLatestValue(); + const hostEntries = layouts[hostName]; + + if (!hostEntries?.length) { + return; + } + + const nextHostEntries = hostEntries.filter((entry) => entry.id !== id); const nextLayouts = { ...layouts }; - delete nextLayouts[hostName]; + + if (nextHostEntries.length === 0) { + delete nextLayouts[hostName]; + } else { + nextLayouts[hostName] = nextHostEntries; + } + closingPortalLayoutsStore.next({ layouts: nextLayouts }); }; @@ -148,14 +214,81 @@ export const useOverlayController = () => { return useStateStore(overlayStore, selector); }; +const overlayClosingSelector = (nextState: OverlayState) => ({ + closing: nextState.closing, +}); + +const closingPortalHostBlacklistSelector = (nextState: OverlayState) => ({ + closingPortalHostBlacklist: nextState.closingPortalHostBlacklist, +}); + +export const useIsOverlayClosing = () => { + return useStateStore(overlayStore, overlayClosingSelector).closing; +}; + +export const useClosingPortalHostBlacklistState = () => { + return useStateStore(overlayStore, closingPortalHostBlacklistSelector).closingPortalHostBlacklist; +}; + +export const useShouldTeleportToClosingPortal = (hostName: string, id: string) => { + const closing = useIsOverlayClosing(); + const closingPortalHostBlacklist = useClosingPortalHostBlacklistState(); + const closingPortalLayouts = useClosingPortalLayouts(); + const hostEntries = closingPortalLayouts[hostName]; + + return ( + closing && + !closingPortalHostBlacklist.includes(hostName) && + hostEntries?.[hostEntries.length - 1]?.id === id + ); +}; + +/** + * Registers a screen-level blacklist of closing portal hosts that should not render while this hook is active. + * + * The blacklist uses stack semantics: + * - mounting/enabling a new instance makes its blacklist active + * - unmounting/disabling restores the previous active blacklist automatically + * + * This keeps stacked screens predictable without requiring previous screens to rerun effects when the top screen + * disappears. + */ +export const useClosingPortalHostBlacklist = (hostNames: string[], enabled = true) => { + const registrationIdRef = useRef(null); + + if (!registrationIdRef.current) { + registrationIdRef.current = createClosingPortalHostBlacklistRegistrationId(); + } + + const registrationId = registrationIdRef.current; + const serializedNormalizedHostNames = JSON.stringify([...new Set(hostNames)]); + + useEffect(() => { + if (!enabled) { + clearClosingPortalHostBlacklist(registrationId); + return; + } + + setClosingPortalHostBlacklist( + registrationId, + JSON.parse(serializedNormalizedHostNames) as string[], + ); + + return () => { + clearClosingPortalHostBlacklist(registrationId); + }; + }, [enabled, registrationId, serializedNormalizedHostNames]); +}; + /** * NOTE: * Do not swap this back to `useStateStore(closingPortalLayoutsStore, selector)`. * * Why this is special: * - `layouts` is a dynamic-key map (hosts are added/removed at runtime) + * - each host key maintains a stack of registrations * - We only need React updates when the key set changes (add/remove/reset) - * - Per-layout movement is already on UI thread via `entry.layout.value` + * - Per-layout movement is already on UI thread via the top `entry.layout.value` * * Why `useStateStore` is unsafe here: * - Both `stream-chat`'s `subscribeWithSelector` and our `useStateStore` snapshot