Info:
This is release candidate documentation for Stream Chat React Native SDK v10. For the latest stable version, see the latest version (v9).

Chat in modals

A chat rendered inside a natively presented modal needs its own OverlayProvider. This page 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 one OverlayProvider at the root of your app, around the navigator, and add another one only for a modal that is presented natively above it.
  • Wrap the modal's own navigator in the nested OverlayProvider, not only 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 for each tab, screen or pane.

Why a modal needs its own OverlayProvider

The message menu that opens on a long press, with the reaction picker and the message actions, renders inside the OverlayProvider, on top of everything it wraps. The image gallery does too. The root OverlayProvider 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, or Expo Router's Stack, 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 menu behind the modal, where the user can't see it. With its own OverlayProvider, the menu 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. A modal with its own OverlayProvider works on both platforms, so the same setup serves 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 the main stack. Hide the header of the screen that presents the modal, so the only header is the inner navigator's, rendered inside the nested OverlayProvider:

App.tsx
import { useHeaderHeight } from "@react-navigation/elements";
import { NavigationContainer } from "@react-navigation/native";
import {
  createNativeStackNavigator,
  type NativeStackScreenProps,
} from "@react-navigation/native-stack";
import { StyleSheet, Text } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import {
  Channel,
  Chat,
  type DeepPartial,
  MessageComposer,
  MessageList,
  OverlayProvider,
  type OverlayProviderProps,
  Streami18n,
  type Theme,
  useChatContext,
  useCreateChatClient,
} from "stream-chat-react-native";

const apiKey = "YOUR_API_KEY";
const userId = "YOUR_USER_ID";
const userToken = "YOUR_USER_TOKEN";

const streami18n = new Streami18n();
const theme: DeepPartial<Theme> = {
  messageList: { container: { backgroundColor: "#FAFAFA" } },
};

// The same props for every OverlayProvider in the app.
const overlayProps: OverlayProviderProps = {
  accessibility: { enabled: true },
  i18nInstance: streami18n,
  value: { style: theme },
};

type RootParamList = {
  Home: undefined;
  SupportChat: undefined;
};

type ModalParamList = {
  SupportChannel: undefined;
};

const RootStack = createNativeStackNavigator<RootParamList>();
const ModalStack = createNativeStackNavigator<ModalParamList>();

const HomeScreen = ({
  navigation,
}: NativeStackScreenProps<RootParamList, "Home">) => (
  <Text onPress={() => navigation.navigate("SupportChat")}>
    Contact support
  </Text>
);

const SupportChannelScreen = () => {
  const { client } = useChatContext();
  const headerHeight = useHeaderHeight();
  const channel = client.channel("messaging", `support-${userId}`);

  return (
    <Channel
      channel={channel}
      keyboardVerticalOffset={headerHeight}
      topInset={headerHeight}
    >
      <MessageList />
      <MessageComposer />
    </Channel>
  );
};

const SupportChat = () => (
  <OverlayProvider {...overlayProps}>
    <ModalStack.Navigator>
      <ModalStack.Screen
        component={SupportChannelScreen}
        name="SupportChannel"
        options={{ title: "Support" }}
      />
    </ModalStack.Navigator>
  </OverlayProvider>
);

const App = () => {
  const client = useCreateChatClient({
    apiKey,
    tokenOrProvider: userToken,
    userData: { id: userId },
  });

  return (
    <SafeAreaProvider>
      <GestureHandlerRootView style={styles.container}>
        {client ? (
          <OverlayProvider {...overlayProps}>
            <Chat client={client} i18nInstance={streami18n}>
              <NavigationContainer>
                <RootStack.Navigator>
                  <RootStack.Screen component={HomeScreen} name="Home" />
                  <RootStack.Screen
                    component={SupportChat}
                    name="SupportChat"
                    options={{
                      headerShown: false,
                      presentation: "fullScreenModal",
                    }}
                  />
                </RootStack.Navigator>
              </NavigationContainer>
            </Chat>
          </OverlayProvider>
        ) : (
          <Text>Connecting...</Text>
        )}
      </GestureHandlerRootView>
    </SafeAreaProvider>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1 },
});

export default App;

The modal renders inside the root Chat, so it doesn't need a Chat of its own. A full screen modal can't be swiped away, so give the inner navigator's header a close button. navigation.goBack() on the first screen of the inner stack reaches the root stack, which dismisses the modal.

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 the group's layout. The props come from a module shared with the root layout:

chat-config.ts
import {
  type DeepPartial,
  type OverlayProviderProps,
  Streami18n,
  type Theme,
} from "stream-chat-expo";

export const streami18n = new Streami18n();

const theme: DeepPartial<Theme> = {
  messageList: { container: { backgroundColor: "#FAFAFA" } },
};

// The same props for every OverlayProvider in the app.
export const overlayProps: OverlayProviderProps = {
  accessibility: { enabled: true },
  i18nInstance: streami18n,
  value: { style: theme },
};
app/(chat-modal)/_layout.tsx
import { Stack } from "expo-router";
import { OverlayProvider } from "stream-chat-expo";

import { overlayProps } from "@/chat-config";

export default function ChatModalLayout() {
  return (
    <OverlayProvider {...overlayProps}>
      <Stack />
    </OverlayProvider>
  );
}

Then present the group as a full screen modal from the root layout, which renders the root OverlayProvider and Chat around its Stack:

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's take precedence over the root one's and over Chat's. Pass it the same value.style, i18nInstance and accessibility as your root OverlayProvider. Without them, the modal falls back to the default theme, to the default English translations and to accessibility turned off. An app that themes the SDK through Chat's style passes the same theme to the nested OverlayProvider's value.style.

The image gallery props, autoPlayVideo, giphyVersion and numberOfImageGalleryGridColumns, apply to the gallery the nested OverlayProvider opens, so pass those too if you set them.

Contexts that OverlayProvider doesn't provide, such as Chat's client and WithComponents' components, still come from above the modal.

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 menu then opens inside the modal, but the modal's native header is drawn above the nested OverlayProvider. The menu 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.

Choosing a presentation

The message menu 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 menu 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.

React Native's Modal

React Native's own Modal component also presents its content in a separate native window, on iOS and on Android, so a chat inside it needs an OverlayProvider inside the Modal, around the chat. On Android, gestures inside a Modal also need a GestureHandlerRootView inside it; see the react-native-gesture-handler installation guide.

Multiple OverlayProviders

The message menu relies on portal hosts that are registered by name in a single native registry, shared by the whole app. So only one OverlayProvider owns the menu at a time: the one mounted most recently. The others keep their hosts under names of their own. 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 the one on screen.

The image gallery opens in the OverlayProvider closest to the image that was tapped, so a gallery opened inside the modal shows inside it.

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

A custom MessageActions from WithComponents is rendered only by the OverlayProvider that owns the menu, so it mounts again when ownership moves to a different OverlayProvider.

Used in