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

Snackbar

The SDK shows in-app notifications, such as "Message pinned" or "Failed to send message", as a snackbar: a card that floats over the bottom of the message list or the channel list, one at a time. NotificationList picks the notification and animates it, and Notification renders the card. This UI cookbook changes how the snackbar looks, from the theme to a card of your own, then moves it to the top of the list as a banner. Which notifications show where, and how they're translated, is explained in Displaying notifications. Publishing your own is on Notifications.

Best practices

  • Restyle through the theme first. Colors, spacing and the list's position need no override.
  • Replace Notification for a different card, and NotificationList for a different place or motion. Register them with WithComponents, so every list in the app uses them.
  • Keep what screen readers get from the default card: a live region that's assertive for errors and polite otherwise, the alert role for errors and summary for the rest, and a translated label on the close button.
  • Show the close button on a notification without a duration. It stays until it's dismissed, so the user needs a way to close it.
  • Spread the props a replaced NotificationList receives. MessageList passes its filter and bottomOffset through them.

Restyling the snackbar

The theme has a style for each part of the card under notification, and one for the list's position under notificationList:

src/theme.ts
import type { DeepPartial, Theme } from "stream-chat-react-native";

// Pass the same object to OverlayProvider (value.style) and Chat (style).
export const theme: DeepPartial<Theme> = {
  notification: {
    container: { borderRadius: 12, paddingHorizontal: 12 },
    message: { fontSize: 15 },
  },
  notificationList: {
    // Raises the snackbar above a toolbar of your own at the bottom of the screen.
    container: { bottom: 72 },
  },
};
A black snackbar reading Upload failed with a Retry button on iOS
A black snackbar reading Upload failed with a Retry button on Android

The card's parts are container, contentContainer, iconContainer, message, actionsContainer, actionButton and closeButton. The theme also has notification.actionButtonText, which the default card doesn't apply.

The card's background is the backgroundCoreInverse token, and its text, icon and close button are textOnInverse. A backgroundColor in notification.container changes the background only, so keep it dark enough for the light icon, or change the two tokens, which other parts of the SDK use too. Theming explains the tokens.

A snackbar of your own

This card has a colored bar on its leading edge for the severity, the message, the notification's actions on a row of their own, and a close button:

src/StripedNotification.tsx
import { Pressable, StyleSheet, Text, View } from "react-native";
import type { Notification as StreamNotification } from "stream-chat";
import {
  useComponentsContext,
  useNotificationApi,
  useTheme,
  useTranslationContext,
} from "stream-chat-react-native";
import type {
  ComponentOverrides,
  NotificationProps,
} from "stream-chat-react-native";

type Semantics = ReturnType<typeof useTheme>["theme"]["semantics"];

const severityColor = (
  notification: StreamNotification,
  semantics: Semantics,
) => {
  switch (notification.severity) {
    case "error":
      return semantics.accentError;
    case "warning":
      return semantics.accentWarning;
    case "success":
      return semantics.accentSuccess;
    default:
      return semantics.accentPrimary;
  }
};

const StripedNotification = ({
  Icon,
  notification,
  onDismiss,
  showClose,
}: NotificationProps) => {
  const { icons, NotificationIcon } = useComponentsContext();
  const { removeNotification } = useNotificationApi();
  const { t } = useTranslationContext();
  const {
    theme: { semantics },
  } = useTheme();

  const ResolvedIcon = Icon ?? NotificationIcon;
  const isError = notification.severity === "error";
  const closeVisible = showClose || !notification.duration;
  const dismiss = onDismiss ?? (() => removeNotification(notification.id));

  return (
    <View
      accessibilityLiveRegion={isError ? "assertive" : "polite"}
      accessibilityRole={isError ? "alert" : "summary"}
      style={[
        styles.card,
        { backgroundColor: semantics.backgroundCoreInverse },
      ]}
    >
      <View
        style={[
          styles.stripe,
          { backgroundColor: severityColor(notification, semantics) },
        ]}
      />
      <View style={styles.body}>
        <View style={styles.row}>
          {ResolvedIcon ? <ResolvedIcon notification={notification} /> : null}
          <Text style={[styles.message, { color: semantics.textOnInverse }]}>
            {notification.message}
          </Text>
          {closeVisible ? (
            <Pressable
              accessibilityLabel={t(
                "notifications.dismiss.accessibilityLabel",
                "Dismiss notification",
              )}
              accessibilityRole="button"
              hitSlop={8}
              onPress={dismiss}
            >
              <icons.NewClose
                height={20}
                stroke={semantics.textOnInverse}
                width={20}
              />
            </Pressable>
          ) : null}
        </View>
        {notification.actions?.length ? (
          <View style={styles.row}>
            {notification.actions.map((action) => (
              <Pressable
                accessibilityRole="button"
                key={action.label}
                onPress={action.handler}
              >
                <Text
                  style={[styles.action, { color: semantics.textOnInverse }]}
                >
                  {action.label}
                </Text>
              </Pressable>
            ))}
          </View>
        ) : null}
      </View>
    </View>
  );
};

// Pass to WithComponents, declared once at module scope.
export const notificationOverrides: ComponentOverrides = {
  Notification: StripedNotification,
};

const styles = StyleSheet.create({
  action: { fontWeight: "700", textDecorationLine: "underline" },
  body: { flex: 1, gap: 8, padding: 12 },
  card: {
    borderRadius: 12,
    flexDirection: "row",
    overflow: "hidden",
    width: "100%",
  },
  message: { flex: 1 },
  row: { alignItems: "center", flexDirection: "row", gap: 12 },
  stripe: { width: 4 },
});
A card with a colored bar on its leading edge reading Upload failed with a Retry action on iOS
A card with a colored bar on its leading edge reading Upload failed with a Retry action on Android
  • NotificationList renders the card with notification, onDismiss and showClose. onDismiss removes the notification from the store and frees the list for the next one. Without it, as when you render the card yourself, the card removes the notification itself through useNotificationApi().
  • showClose is true for a notification without a duration, such as one with the loading severity. The card also checks duration itself, so it behaves the same outside the list.
  • The icon is the NotificationIcon key's, unless the list passes Icon. The default icon has the textOnInverse color, which suits the card's inverse background. A notification with no severity has no icon.
  • notification.actions holds buttons the publisher added, such as Retry. Each one runs its handler when pressed. The list keeps a notification with actions on screen for at least five seconds.
  • The dismiss label is the SDK's own translation key, so it's translated with the rest of the SDK.

The card shows notification.message as it is. The SDK's components translate their messages before they publish them, and your own code can do the same. The notifications stream-chat publishes, such as a failed upload, carry English text in message: the default card translates them by their type, through a mapping the SDK doesn't export. To keep those translated, keep the default card and change its look through the theme, or wrap it, as Replacing the list or a notification does.

A banner at the top of the list

NotificationList places the card for you. To change where it goes and how it moves, replace the list and pick the notification with useNotificationListController. It does what the default list does: it picks the notification to show, starts its timer, removes the others for the same target, and clears the target's notifications when the list unmounts. This list shows the card as a banner across the top of the message list or the channel list, and slides it in and out:

src/BannerNotificationList.tsx
import { StyleSheet } from "react-native";
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
import {
  useComponentsContext,
  useNotificationListController,
} from "stream-chat-react-native";
import type {
  ComponentOverrides,
  NotificationListProps,
} from "stream-chat-react-native";

const BannerNotificationList = ({
  filter,
  hostId,
  panel,
}: NotificationListProps) => {
  const { Notification } = useComponentsContext();
  const { dismissNotification, notification } = useNotificationListController({
    filter,
    hostId,
    panel,
  });

  if (!notification) return null;

  return (
    <Animated.View
      entering={SlideInUp.duration(200)}
      exiting={SlideOutUp.duration(160)}
      key={notification.id}
      pointerEvents="box-none"
      style={styles.banner}
    >
      <Notification
        notification={notification}
        onDismiss={dismissNotification}
        showClose={!notification.duration}
      />
    </Animated.View>
  );
};

// Pass to WithComponents, declared once at module scope.
export const notificationListOverrides: ComponentOverrides = {
  NotificationList: BannerNotificationList,
};

const styles = StyleSheet.create({
  banner: { left: 8, position: "absolute", right: 8, top: 8, zIndex: 20 },
});
  • The list passes filter on, so the filter MessageList adds, which hides the upload failures a user causes by removing an attachment, still applies. It ignores bottomOffset, which matters only at the bottom.
  • key={notification.id} gives each notification its own view, so one leaving slides out while the next slides in.
  • The banner renders the Notification key, so it shows the default card, or the striped card above when both overrides are registered.
  • Reanimated's animations follow the system's reduce-motion setting by default, so with reduced motion on, the banner appears and leaves without sliding.

The message list shows its "Waiting for network..." and "Reconnecting..." bar at the same place, at the top of the list. A banner shown while the connection is down covers it; move one of them, with the banner's top style or with the NetworkDownIndicator key as Connection status shows.

For the default list at the top without your own motion, register a NotificationList that renders the default one with the props it receives and verticalAlignment="top" and enterFrom="top" instead, as Replacing the list or a notification does. The lists render the key without those props, so they can't be passed from outside.

Several notifications at once

useNotificationListController shows one notification and removes the others for its target, so a list that stacks them reads the notifications with useNotifications instead, and does the rest of the controller's work itself. This list shows the newest three, one above the other:

src/StackedNotificationList.tsx
import { useCallback, useEffect, useRef } from "react";
import { StyleSheet, View } from "react-native";
import type { Notification as StreamNotification } from "stream-chat";
import {
  hasSystemNotificationTag,
  isNotificationForTarget,
  registerActiveNotificationTarget,
  resolveNotificationTargetTagIfNeeded,
  useChatContext,
  useComponentsContext,
  useNotificationApi,
  useNotifications,
  useResolvedNotificationTarget,
} from "stream-chat-react-native";
import type {
  ComponentOverrides,
  NotificationListProps,
} from "stream-chat-react-native";

const MAX_VISIBLE = 3;
const ACTION_DURATION = 5000;

const StackedNotificationList = ({
  bottomOffset = 0,
  filter,
  hostId,
  panel,
}: NotificationListProps) => {
  const { client } = useChatContext();
  const { Notification } = useComponentsContext();
  const { removeNotification, startNotificationTimeout } = useNotificationApi();
  const target = useResolvedNotificationTarget({ hostId, panel });
  const startedIds = useRef(new Set<string>());

  const isShown = useCallback(
    (notification: StreamNotification) =>
      !hasSystemNotificationTag(notification) &&
      (filter ? filter(notification) : true),
    [filter],
  );
  const notifications = useNotifications({
    filter: isShown,
    requireTarget: true,
    target,
  });

  // A notification published without a target gets the target of the list
  // mounted last, as with the default list.
  useEffect(
    () =>
      client.notifications.store.addPreprocessor((next, previous) => {
        const known = new Set(previous?.notifications.map(({ id }) => id));
        next.notifications.forEach((notification) => {
          if (known.has(notification.id)) return;
          if (hasSystemNotificationTag(notification)) return;
          resolveNotificationTargetTagIfNeeded(
            client.notifications,
            notification,
          );
        });
      }),
    [client],
  );

  useEffect(() => {
    if (!target) return;
    return registerActiveNotificationTarget(client.notifications, target);
  }, [client, target]);

  // Starts each timer once. Starting it again would restart the countdown.
  useEffect(() => {
    const ids = new Set(notifications.map(({ id }) => id));
    startedIds.current.forEach((id) => {
      if (!ids.has(id)) startedIds.current.delete(id);
    });
    notifications.forEach((notification) => {
      if (startedIds.current.has(notification.id)) return;
      startedIds.current.add(notification.id);
      const keepForActions =
        notification.duration && notification.actions?.length
          ? Math.max(notification.duration, ACTION_DURATION)
          : undefined;
      startNotificationTimeout(notification.id, keepForActions);
    });
  }, [notifications, startNotificationTimeout]);

  // Clears this target's notifications when the list unmounts.
  useEffect(() => {
    if (!target) return;
    return () => {
      client.notifications.notifications
        .filter(
          (notification) =>
            !hasSystemNotificationTag(notification) &&
            isNotificationForTarget(notification, target),
        )
        .forEach(({ id }) => removeNotification(id));
    };
  }, [client, removeNotification, target]);

  if (!notifications.length) return null;

  return (
    <View
      pointerEvents="box-none"
      style={[styles.stack, { bottom: 16 + bottomOffset }]}
    >
      {notifications.slice(-MAX_VISIBLE).map((notification) => (
        <Notification
          key={notification.id}
          notification={notification}
          showClose={!notification.duration}
        />
      ))}
    </View>
  );
};

// Pass to WithComponents, declared once at module scope.
export const stackedNotificationListOverrides: ComponentOverrides = {
  NotificationList: StackedNotificationList,
};

const styles = StyleSheet.create({
  stack: { gap: 8, left: 16, position: "absolute", right: 16, zIndex: 20 },
});
  • useNotifications with requireTarget and the list's target returns only the notifications for this list, so each list in the app shows its own. Without a target, outside the components that provide one, such as Channel and ChannelList, it returns none.
  • filter keeps the notifications useNotifications returns to the ones this list shows. Here it drops system notifications, which aren't snackbars, and applies the filter the list receives.
  • The list registers its target with registerActiveNotificationTarget. The preprocessor gives a notification published without a target, such as one from stream-chat, the target of the list registered last, unless the composer or a running action supplies one.
  • The manager doesn't start a notification's timer when it's added. startNotificationTimeout starts it, and starting it again restarts the countdown, so the list remembers which timers it started. A notification with actions stays for at least five seconds, as in the default list. One without a duration stays until the user closes it.
  • The cards are rendered without onDismiss, so each one removes its notification itself when its close button is pressed.
  • The default list removes its target's notifications when it unmounts, so a notification that's still waiting doesn't show up the next time the screen opens. This one does too.

Register stackedNotificationListOverrides in place of notificationListOverrides below.

Register them

Wrap the app, or the part that should use them, in WithComponents:

src/App.tsx
import type { PropsWithChildren } from "react";
import type { StreamChat } from "stream-chat";
import { Chat, WithComponents } from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";
import { notificationListOverrides } from "./BannerNotificationList";
import { notificationOverrides } from "./StripedNotification";

// Declared once: WithComponents reads its overrides when it mounts.
const overrides: ComponentOverrides = {
  ...notificationOverrides,
  ...notificationListOverrides,
};

export const ChatWithBanners = ({
  children,
  client,
}: PropsWithChildren<{ client: StreamChat }>) => (
  <WithComponents overrides={overrides}>
    <Chat client={client}>{children}</Chat>
  </WithComponents>
);

MessageList, MessageFlashList, ChannelList and ThreadList render the NotificationList key, so the banner shows on each of them. The screens of channel details render the default list directly, so they keep the default snackbar, with your card inside it.

A message list of your own doesn't render a notification list. Render <NotificationList /> next to it, inside Channel or Thread, which provide the notification target it shows. Notification lists and targets lists the targets.