Mentions inbox

This UI cookbook builds a Mentions screen that lists the messages that mention the user, newest first, with a Load more button under them, and opens a mention in its channel at its message. The screen searches with a MessageSearchSource, which Search sources explains, and opens the message the way Moving between channels, threads and messages describes.

Note:

What you'll build

  • A Mentions screen that lists the messages that mention the user, newest first
  • A Load more button under the mentions
  • Opening a mention in its channel at its message

Approach: Compose and Change behavior in the state layer. Choosing where to start explains the approaches.

Best practices

  • Find the mentions with a MessageSearchSource and a message filter on mentioned_users.id. The source pages the results, keeps them in its state store, and loads the channels they come from that the client doesn't hold yet, so the screen never calls client.search() itself.
  • Give the source no search text. Set allowEmptySearchString so the filter alone picks the messages, and debounceMs to 0, since nothing is typed.
  • Load the next page from a button at the end of the list. The user decides when older mentions arrive, and the list never jumps under their finger.
  • Create the source once for the screen. Activate it when the screen mounts, and cancel its queries and deactivate it when the screen unmounts, so it lets go of the channels its results kept in memory.
  • Open a mention by navigating with the channel's cid and the message's id, and jump to the message when the channel screen opens.
  • Show the loading, empty and error states. The error state offers to try again.

The Mentions screen

createMentionsSource() creates a MessageSearchSource whose messageSearchFilters keep the messages with the user's id in mentioned_users. The source adds the filter that keeps the search to the channels the user is a member of, and to messages of type regular.

  • MessageSearchSource.search("") loads the first page, and MessageSearchSource.search() with no text loads the next one. It does nothing while a query runs or when hasNext is false.
  • state.items is undefined until the first query ends, so the screen shows its loading indicator until then. A first page that fails leaves lastQueryError set and no items, and the screen offers Try again. A later page that fails keeps what loaded.
  • Each row shows the author's avatar and name, the channel's name from useChannelPreviewDisplayName(), the time, and the text with the user's own mention in bold. The channel comes from client.channelManager.get(message.cid), where the source put it.
  • Search returns the server's form of a message, MessageResponse. Its created_at is a nanosecond timestamp, so convertTimestampToDate() turns it into the date getDateString() formats, with the same key the channel list uses for its rows.
  • onOpen receives the message. The navigator, shown after the screen, passes a function that pushes the channel screen.
src/MentionsInbox.tsx
import { useEffect, useMemo, useState } from "react";
import { FlatList, Pressable, StyleSheet, Text, View } from "react-native";
import { convertTimestampToDate, MessageSearchSource } from "stream-chat";
import type {
  MessageResponse,
  SearchSourceState,
  StreamChat,
} from "stream-chat";
import {
  Button,
  getDateString,
  LoadingIndicator,
  useChannelPreviewDisplayName,
  useChatContext,
  useStateStore,
  useTheme,
  useTranslationContext,
  UserAvatar,
} from "stream-chat-react-native";

// The messages that mention the user, newest first, a page at a time.
const createMentionsSource = (client: StreamChat) =>
  new MessageSearchSource(client, {
    // There is no search text: the filter alone picks the messages.
    allowEmptySearchString: true,
    // Nothing is typed, so the source queries right away.
    debounceMs: 0,
    messageSearchFilters: {
      "mentioned_users.id": { $contains: client.userId ?? "" },
    },
    pageSize: 20,
  });

// Declared at module scope, so the subscription isn't recreated on every render.
const sourceSelector = ({
  hasNext,
  isLoading,
  items,
  lastQueryError,
}: SearchSourceState<MessageResponse>) => ({
  hasNext,
  isLoading,
  items,
  lastQueryError,
});

const MentionText = ({ mention, text }: { mention: string; text: string }) => {
  const {
    theme: { semantics },
  } = useTheme();
  const parts = text.split(mention);

  return (
    <Text numberOfLines={3} style={{ color: semantics.textPrimary }}>
      {parts.map((part, index) => (
        <Text key={index}>
          {part}
          {index < parts.length - 1 ? (
            <Text style={styles.mention}>{mention}</Text>
          ) : null}
        </Text>
      ))}
    </Text>
  );
};

const MentionRow = ({
  message,
  onOpen,
}: {
  message: MessageResponse;
  onOpen: (message: MessageResponse) => void;
}) => {
  const { client } = useChatContext();
  const { t, tDateTimeParser } = useTranslationContext();
  const {
    theme: { semantics },
  } = useTheme();
  const channel = client.channelManager.get(message.cid);
  const channelName = useChannelPreviewDisplayName(channel);
  const time = useMemo(
    () =>
      getDateString({
        messageCreatedAt: convertTimestampToDate(message.created_at),
        t,
        tDateTimeParser,
        timestampTranslationKey: "timestamp.ChannelPreviewStatus",
      }),
    [message.created_at, t, tDateTimeParser],
  );
  const author = message.user;

  return (
    <Pressable
      accessibilityRole="button"
      onPress={() => onOpen(message)}
      style={[styles.row, { borderBottomColor: semantics.borderCoreSubtle }]}
    >
      {author ? <UserAvatar size="lg" user={author} /> : null}
      <View style={styles.rowText}>
        <View style={styles.rowHeader}>
          <Text
            numberOfLines={1}
            style={[styles.author, { color: semantics.textPrimary }]}
          >
            {author?.name ?? author?.id}
          </Text>
          <Text style={{ color: semantics.textTertiary }}>{time}</Text>
        </View>
        <Text numberOfLines={1} style={{ color: semantics.textSecondary }}>
          {channelName}
        </Text>
        <MentionText
          mention={`@${client.user?.name ?? client.userId}`}
          text={message.text ?? ""}
        />
      </View>
    </Pressable>
  );
};

export const MentionsInbox = ({
  onOpen,
}: {
  onOpen: (message: MessageResponse) => void;
}) => {
  const { client } = useChatContext();
  const {
    theme: { semantics },
  } = useTheme();
  const [source] = useState(() => createMentionsSource(client));
  const { hasNext, isLoading, items, lastQueryError } = useStateStore(
    source.state,
    sourceSelector,
  );

  useEffect(() => {
    source.activate();
    void source.search("");
    // Leaving the screen drops a query that is waiting or running, and lets go of the channels
    // the results held.
    return () => {
      source.cancelScheduledQuery();
      source.deactivate();
    };
  }, [source]);

  if (lastQueryError && !items?.length) {
    return (
      <View style={styles.note}>
        <Text style={{ color: semantics.textSecondary }}>
          Couldn't load your mentions.
        </Text>
        <Button
          label="Try again"
          onPress={() => void source.search("")}
          type="outline"
          variant="secondary"
        />
      </View>
    );
  }

  if (!items) return <LoadingIndicator />;

  if (!items.length) {
    return (
      <View style={styles.note}>
        <Text style={{ color: semantics.textSecondary }}>
          No one has mentioned you yet.
        </Text>
      </View>
    );
  }

  return (
    <FlatList
      data={items}
      keyExtractor={(message) => message.id}
      ListFooterComponent={
        hasNext ? (
          <View style={styles.footer}>
            <Button
              disabled={isLoading}
              label={isLoading ? "Loading..." : "Load more"}
              // Without text, `search()` loads the next page of the current search.
              onPress={() => void source.search()}
              type="outline"
              variant="secondary"
            />
          </View>
        ) : null
      }
      renderItem={({ item }) => <MentionRow message={item} onOpen={onOpen} />}
    />
  );
};

const styles = StyleSheet.create({
  author: { flex: 1, fontWeight: "600" },
  footer: { alignItems: "center", padding: 16 },
  mention: { fontWeight: "700" },
  note: { alignItems: "flex-start", gap: 12, padding: 16 },
  row: {
    borderBottomWidth: StyleSheet.hairlineWidth,
    flexDirection: "row",
    gap: 12,
    padding: 12,
  },
  rowHeader: { alignItems: "center", flexDirection: "row", gap: 8 },
  rowText: { flex: 1, gap: 2 },
});
The Mentions screen listing messages that mention the user, newest first, with Cleo in bold and a Load more button on iOS
The Mentions screen listing messages that mention the user, newest first, with Cleo in bold and a Load more button on Android

MentionText splits the text on @ and the user's name, which is what the composer inserts when the user picks them from its suggestions. The Load more button is disabled while a query runs, which MessageSearchSource.search() would ignore anyway.

Open a mention at its message

The channel screen takes the message's id in its route params and jumps to it in a layout effect, as on Jumping when a screen opens. The source loaded the message's channel into client.channelManager without watching it, and Channel watches it when it mounts. The screen turns off the jump to the first unread message for that open, so it doesn't compete with the jump to the mention:

src/ChannelScreen.tsx
import { useLayoutEffect } from "react";
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import {
  Channel,
  DEFAULT_HIGHLIGHT_DURATION,
  MessageComposer,
  MessageList,
  useChatContext,
} from "stream-chat-react-native";

export type RootStackParamList = {
  ChannelList: undefined;
  Channel: { cid: string; messageId?: string };
  Mentions: undefined;
};

export const ChannelScreen = ({
  route,
}: NativeStackScreenProps<RootStackParamList, "Channel">) => {
  const { cid, messageId } = route.params;
  const { client } = useChatContext();
  const channel = client.channelManager.get(cid);

  // A layout effect runs before the list picks where it starts, so the list starts at the message.
  useLayoutEffect(() => {
    if (!channel || !messageId) return;
    void channel.messagePaginator.jumpToMessage(messageId, {
      focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
    });
  }, [channel, messageId]);

  if (!channel) return null;

  return (
    <Channel channel={channel} initialScrollToFirstUnreadMessage={!messageId}>
      <MessageList />
      <MessageComposer />
    </Channel>
  );
};
A channel opened at the mention, with the message highlighted, on iOS
A channel opened at the mention, with the message highlighted, on Android

Channel also takes the keyboardVerticalOffset and topInset your screen needs; see Keyboard and insets.

Add the screen to the stack

The Mentions screen is a screen of the stack. A button in the channel list's header opens it, and a tap on a mention navigates to the channel screen:

src/Navigation.tsx
import type { ComponentType } from "react";
import { Button } from "react-native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import { MentionsInbox } from "./MentionsInbox";
import { ChannelScreen } from "./ChannelScreen";
import type { RootStackParamList } from "./ChannelScreen";

const Stack = createNativeStackNavigator<RootStackParamList>();

const MentionsScreen = ({
  navigation,
}: NativeStackScreenProps<RootStackParamList, "Mentions">) => (
  <MentionsInbox
    onOpen={(message) =>
      navigation.navigate("Channel", {
        cid: message.cid,
        messageId: message.id,
      })
    }
  />
);

// Your channel list screen, with a ChannelList that opens the channel screen on select.
declare const ChannelListScreen: ComponentType;

export const Navigation = () => (
  <Stack.Navigator>
    <Stack.Screen
      component={ChannelListScreen}
      name="ChannelList"
      options={({ navigation }) => ({
        headerRight: () => (
          <Button
            onPress={() => navigation.navigate("Mentions")}
            title="Mentions"
          />
        ),
        title: "Chats",
      })}
    />
    <Stack.Screen component={ChannelScreen} name="Channel" />
    <Stack.Screen component={MentionsScreen} name="Mentions" />
  </Stack.Navigator>
);

Wrap Navigation in NavigationContainer, Chat and OverlayProvider, as on Navigation.

How it works

Info:

Example

Cara writes "@Cleo you were great on the music round." in Quiz night, mentioning the user, Cleo:

  1. The composer sends the message with Cleo's id in mentioned_users.
  2. Cleo taps Mentions. MentionsInbox activates its source and calls MessageSearchSource.search(""). The source searches the messages whose mentioned_users.id contains Cleo's id, newest first, 20 at a time, in the channels she is a member of. It then loads Quiz night and the other result channels the client doesn't hold yet, without watching them.
  3. Cara's message is first in the list, with "@Cleo" in bold. The Load more button shows under the list while state.hasNext is true.
  4. Cleo taps the mention. The stack pushes Channel with Quiz night's cid and the message's id. ChannelScreen calls MessagePaginator.jumpToMessage() in its layout effect, and Channel watches the channel when it mounts. The list shows Cara's message and highlights it. The jump sends a request only when the message isn't loaded.
  • The results are a snapshot. A mention that arrives while the screen is open doesn't join the list; leaving the screen and opening it again searches again.
  • The source keeps to messages of type regular, so mentions in thread replies aren't listed. Add type: { $in: ["regular", "reply"] } to messageSearchFilters to list them. A reply isn't in the channel's list, so open its parent_id in the channel, or open the thread and jump to the reply there, as Jumping when a screen opens describes.
  • mentioned_users holds the users the composer picked from its suggestions. Typing "@Cleo" without picking Cleo leaves mentioned_users empty, and the message isn't a mention. @channel, @here and the mentions of roles and user groups aren't in mentioned_users, so they aren't listed either.
  • Message search covers only the channel types whose search setting is on; see Search.

Read more