Chat in Modals

A chat rendered inside a natively presented modal needs its own OverlayProvider. This guide shows where to put it, which props to pass, and what to keep in mind when more than one OverlayProvider is mounted at the same time.

Best practices

  • Keep a single OverlayProvider at the root of your app, above NavigationContainer, and add another one only for modals that are presented natively above it.
  • Giving such a modal its own OverlayProvider never hurts, on iOS or on Android. The root OverlayProvider keeps working and takes over again once the modal is dismissed.
  • Wrap the modal's own navigator in the nested OverlayProvider, not just the screen.
  • Pass the nested OverlayProvider the same props as the root one.
  • Use presentation: 'fullScreenModal' for modals that contain a chat.
  • Never mount OverlayProviders side by side, for example one per tab, one per screen or one per pane.

Why a modal needs its own OverlayProvider

The message overlay (the long press menu with the reaction picker and the message actions) and the image gallery render inside the OverlayProvider, on top of everything it wraps. The root OverlayProvider therefore covers every screen of your navigation stack, as long as those screens are drawn in the same native view.

On iOS, a modal of a native stack navigator (createNativeStackNavigator with presentation: 'fullScreenModal', 'modal', 'pageSheet' or 'formSheet') is presented in a separate view controller, on top of the root one. The root OverlayProvider stays behind it. A long press inside the modal hides the message and opens the overlay behind the modal, where it can't be seen. With its own OverlayProvider, the overlay renders inside the modal.

On Android, native stack modals are drawn in the same window as the rest of the app, so the root OverlayProvider already covers them.

Having its own OverlayProvider doesn't hurt the modal on either platform. While the modal is presented, its OverlayProvider owns the overlay, and once the modal is dismissed the root OverlayProvider takes over again, so chats outside the modal keep working as before. You can use the same setup on iOS and Android.

Setting it up with React Navigation

Give the modal its own navigator and wrap that navigator in an OverlayProvider, the same way the root OverlayProvider wraps your main navigation stack. Hide the header of the screen that presents the modal, so the only header is the one of the inner navigator, which is rendered inside the nested OverlayProvider.

import React from "react";
import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { Chat, OverlayProvider } from "stream-chat-react-native";

const RootStack = createNativeStackNavigator();
const ModalStack = createNativeStackNavigator();

const ChatModal = () => (
  <OverlayProvider value={{ style: theme }} i18nInstance={streami18n}>
    <ModalStack.Navigator>
      <ModalStack.Screen
        name="ModalChannel"
        component={ModalChannelScreen}
        options={{ title: "Support" }}
      />
    </ModalStack.Navigator>
  </OverlayProvider>
);

export const App = () => (
  <OverlayProvider value={{ style: theme }} i18nInstance={streami18n}>
    <NavigationContainer>
      <Chat client={client} i18nInstance={streami18n}>
        <RootStack.Navigator>
          <RootStack.Screen name="Channel" component={ChannelScreen} />
          <RootStack.Screen
            name="ChatModal"
            component={ChatModal}
            options={{ headerShown: false, presentation: "fullScreenModal" }}
          />
        </RootStack.Navigator>
      </Chat>
    </NavigationContainer>
  </OverlayProvider>
);

ModalChannelScreen renders Channel, MessageList and MessageComposer like any other channel screen. The modal is still rendered inside the root Chat, so it doesn't need a Chat of its own.

Setting it up with Expo Router

With Expo Router, put the modal's screens in a route group and wrap the group's Stack in an OverlayProvider in its layout:

app/(chat-modal)/_layout.tsx
import { Stack } from "expo-router";
import { OverlayProvider } from "stream-chat-expo";

export default function ChatModalLayout() {
  return (
    <OverlayProvider value={{ style: theme }} i18nInstance={streami18n}>
      <Stack />
    </OverlayProvider>
  );
}

Then present the group as a full screen modal from the root layout:

app/_layout.tsx
<Stack>
  <Stack.Screen
    name="(chat-modal)"
    options={{ headerShown: false, presentation: "fullScreenModal" }}
  />
</Stack>

Passing the same props as the root OverlayProvider

Besides the overlay, OverlayProvider sets up the theme, the translations and the accessibility configuration for everything it wraps, and inside the modal the nested OverlayProvider takes precedence over the root one. Pass it the same value.style, i18nInstance and accessibility as your root OverlayProvider. Without them, the modal falls back to the default theme, to English and to accessibility turned off.

Contexts that OverlayProvider doesn't provide, such as the ones from Chat and WithComponents, are still inherited from the root of your app.

Wrapping the navigator instead of the screen

You can also wrap only the modal's screen, for example through the layout prop of a Stack.Screen. The overlay then opens inside the modal, but the native header of the modal is drawn above the nested OverlayProvider. The overlay can't cover the header, and since it's positioned as if the OverlayProvider started at the top of the screen, it ends up shifted down by the height of the header.

Wrapping the modal's own navigator puts the header inside the OverlayProvider and avoids both issues.

Choosing a presentation

The overlay is positioned as if its OverlayProvider started at the top left corner of the screen, which is the case for fullScreenModal. On iOS, modal, pageSheet and formSheet are presented as sheets that start below the top of the screen, so the overlay is offset by the distance between the top of the screen and the top of the sheet. Use fullScreenModal for modals that contain a chat.

Multiple OverlayProviders

The message overlay relies on portal hosts that are registered by name in a single native registry, shared by the whole app. Because of that, only one OverlayProvider can own the overlay at a time, and that is always the one mounted most recently. When the modal is presented, its OverlayProvider takes over. When the modal is dismissed and its OverlayProvider unmounts, the root OverlayProvider takes over again. This matches how modals behave, since the most recently presented modal is always the one on screen.

It also means that OverlayProviders must not be mounted side by side, as siblings that are visible or stay mounted at the same time. Typical examples are an OverlayProvider per tab of a tab navigator (tabs stay mounted once visited), one per screen, or one per pane of a split view. The last mounted sibling owns the overlay even while a chat under another one is being used, so a long press opens the overlay inside the wrong OverlayProvider, for example in a tab that isn't visible.

A custom MessageActions override is rendered only by the OverlayProvider that owns the overlay at that moment, so it is mounted again when ownership moves to a different OverlayProvider.

Keep one OverlayProvider at the root and add nested ones only for modals that are presented above it.