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

Replies and threads

What users get

Any message can start a thread: a conversation of replies under it, shown on its own screen. Users also get a thread list with every thread they take part in, and can send a reply to the channel as well.

  • Starting and opening a thread. Thread Reply in a message's menu opens the message's thread. A message with replies shows a "3 Replies" button under it, with the avatars of the people who replied, and pressing it opens the thread.
  • The thread screen. The parent message sits at the top, followed by a "3 Replies" divider, the replies and a composer. The typing indicator at the bottom of the list shows who is typing a reply.
  • Also sending to the channel. The thread's composer has an "Also send to channel" checkbox. Such a reply shows "Also sent in channel" in the thread, and "Replied to a thread" in the channel.
  • The thread list. A list of the user's threads. Each row shows the channel's name, the parent message, the avatars of the participants, the reply count, the time of the latest reply and the user's unread count. When threads that aren't in the list get new replies, a banner ("2 new threads") offers to reload it.

The SDK has no thread header: the screen that renders the thread has your app's navigation header.

How it works across the layers

State: a thread and its replies

A thread is identified by its parent message: thread.id is the parent message's id. A reply is a message with a parent_id. With show_in_channel: true, it's also shown in the channel's message list.

There is one Thread instance per thread, shared by every component that shows it. client.threads.ensure({ channel, parentMessage }) returns the existing instance or creates one, and client.threads.get(id) looks one up. A Thread holds what a channel holds, for its replies:

  • thread.state: the parent message, the reply count, the participants, read state, and the flags isLoading and isStateStale;
  • thread.messagePaginator: the replies;
  • thread.messageComposer: the reply being written, with showReplyInChannel for the "Also send to channel" choice;
  • thread.messageOperations: sending, editing and deleting replies. Requests go through the thread's channel, so a thread uses its channel's request handlers.

A thread created from its parent message knows only that message. It starts stale when the parent has replies or its channel isn't watched, and Thread.reload() loads its replies, participants and read state. See What channels and threads hold for what a thread holds and how unread threads are counted.

UI: opening a thread

The SDK doesn't navigate. Thread Reply and the replies button call onThreadSelect on the MessageList (or MessageFlashList), with the thread from client.threads.ensure({ channel, parentMessage: message }). Your callback opens a screen that renders the thread:

  • Thread binds the thread to the components inside it, and renders only its children. It reloads the thread while its state is stale, loads the first page of replies when none are loaded, and activates the thread while it's mounted. An active thread marks new replies read as they arrive.
  • MessageList inside it renders thread.messagePaginator. Its footer, ThreadFooterComponent, shows the parent message and the reply count divider. The list is inverted, so the footer sits at the top.
  • MessageComposer inside it drives thread.messageComposer, and shows the "Also send to channel" checkbox (ShowThreadMessageInChannelButton) while allowThreadMessagesInChannel on Thread is true, which is the default.

Thread doesn't need a Channel around it. When the thread's channel hasn't been watched, for example when the thread was opened from the thread list, Thread watches it once, so the user's capabilities in the channel are known. The navigation itself is on Navigation and Moving between channels, threads and messages.

SentToChannelHeader renders "Also sent in channel" and "Replied to a thread". It adds a View link only when you pass onAlsoSentToChannelHeaderPress to Channel and Thread. The handler receives the id of the message to show, and in a channel also the reply's thread, so you can navigate to the other copy.

The thread list

The thread list is client.threads, the thread manager:

  • client.threads.paginator holds the user's threads. Each page loads 25 threads, with up to 10 participants and the latest 10 replies of each.
  • client.threads.state holds unreadThreadCount, unseenThreadIds and isThreadOrderStale.

ThreadList renders the paginator's threads, one ThreadListItem per thread, and loads the next page when the user scrolls to the end. Pressing a row calls its onThreadSelect prop with the thread. While its isFocused prop is true, which is the default, ThreadList keeps the thread manager active, and it deactivates the manager when it unmounts. Each activation reloads the list when it's empty, stale or has unseen threads. Pass the screen's focus state as isFocused, so a list on a tab that isn't shown stops reloading.

Chat starts the thread manager's event handling, so unreadThreadCount stays current even while no thread list is on screen.

Order

The list is in the order the server returns. The client doesn't sort it. A thread that gets a new reply updates its row in place, without moving.

New threads

When a thread gets a reply (notification.thread_message_new), the thread manager doesn't insert or move anything:

  • If the thread is in the list, it sets isThreadOrderStale, because the server might now order the list differently.
  • If it isn't, it adds the thread's id to unseenThreadIds. ThreadListUnreadBanner shows "N new threads" while that list isn't empty, and pressing it calls client.threads.reload().

Both start only once the first page has loaded.

Reloading the list

client.threads.reload() asks the server for the first page again, sized to the threads already loaded plus the unseen ones (up to one page), and replaces the list in server order. Then it clears unseenThreadIds and isThreadOrderStale.

  • Without force, it reloads only when the list is empty, stale or has unseen threads. The banner and each activation call it this way.
  • client.threads.reload({ force: true }) always reloads.
  • After a reconnect, the thread manager reloads the list by itself, if it was ever activated. It does this on connection.recovered, which isn't dispatched after a reconnect when connection recovery is turned off.

The current threads stay on screen until the new page arrives. If the request fails, the list stays as it was, client.threads.paginator.lastQueryError holds the error, and the banner offers to retry while there are unseen threads to load.

Turning it on or off

Replies are on by default. Two settings must both allow them:

  • On the client: channel.replies.enabled in client.config.
  • On the server: the channel type's replies setting, in the dashboard. When the server turns replies off, the client can't turn them on.
client.config.set({ channel: { replies: { enabled: false } } });

channel.config.replies.enabled holds the value in effect. The SDK's components don't read it: they follow the send-reply capability, which the server derives from the user's role and the channel type's settings. Without it, the message menu leaves out Thread Reply. The replies button still shows on messages that have replies.

To hide Thread Reply in your UI, pass overrideOwnCapabilities={{ sendReply: false }} to Channel, and don't render the thread screen.

Customizing

Composing the thread screen

Thread renders its children, so the screen is whatever you put inside: your own header, a MessageFlashList instead of MessageList, or extra content. Thread takes the same configuration props as Channel, and a Channel around it passes none down, so give both the same ones:

import type {
  Channel as StreamChannel,
  Thread as StreamThread,
} from "stream-chat";
import {
  Channel,
  MessageComposer,
  MessageList,
  Thread,
} from "stream-chat-react-native";

export const ChannelScreen = ({
  channel,
  onOpenThread,
}: {
  channel: StreamChannel;
  onOpenThread: (thread: StreamThread) => void;
}) => (
  <Channel channel={channel} enforceUniqueReaction>
    <MessageList onThreadSelect={onOpenThread} />
    <MessageComposer />
  </Channel>
);

export const ThreadScreen = ({ thread }: { thread: StreamThread }) => (
  <Thread enforceUniqueReaction thread={thread}>
    <MessageList />
    <MessageComposer />
  </Thread>
);

onOpenThread navigates to the screen that renders ThreadScreen. See Thread UI.

Replacing the parent message area

The parent message and the divider are the thread list's footer. Pass FooterComponent to the MessageList inside Thread to replace them. This one shows the parent's text and how many people replied:

import { Text, View } from "react-native";
import type { Thread as StreamThread, ThreadState } from "stream-chat";
import {
  MessageComposer,
  MessageList,
  Thread,
  useStateStore,
  useThreadContext,
} from "stream-chat-react-native";

const parentSelector = (state: ThreadState) => ({
  parentMessage: state.parentMessage,
  participants: state.participants,
  replyCount: state.replyCount,
});

const ThreadStartSummary = () => {
  const { thread } = useThreadContext();
  const { parentMessage, participants, replyCount } =
    useStateStore(thread?.state, parentSelector) ?? {};

  if (!parentMessage) return null;

  return (
    <View>
      <Text>{parentMessage.text}</Text>
      <Text>
        {replyCount ?? 0} replies from {participants?.length ?? 0} people
      </Text>
    </View>
  );
};

export const ThreadScreen = ({ thread }: { thread: StreamThread }) => (
  <Thread thread={thread}>
    <MessageList FooterComponent={ThreadStartSummary} />
    <MessageComposer />
  </Thread>
);

The default footer also shows the loading indicator for older replies. To keep the parent message and add to it, render ThreadFooterComponent inside your footer. It takes parentMessagePreventPress, true by default, which keeps presses on the parent message from doing anything.

Replacing a thread list row

ThreadListItem is an override key. A replacement reads its row from useThreadListItemContext(), and opens the thread with onThreadSelect from useThreadsContext():

import { Pressable, Text } from "react-native";
import {
  useThreadListItemContext,
  useThreadsContext,
  WithComponents,
} from "stream-chat-react-native";

const CompactThreadRow = () => {
  const { lastReply, ownUnreadMessageCount, parentMessage, thread } =
    useThreadListItemContext();
  const { onThreadSelect } = useThreadsContext();

  return (
    <Pressable onPress={() => onThreadSelect?.(thread)}>
      <Text numberOfLines={1}>{parentMessage?.text}</Text>
      <Text numberOfLines={1}>{lastReply?.text}</Text>
      {ownUnreadMessageCount > 0 ? (
        <Text>{ownUnreadMessageCount} new</Text>
      ) : null}
    </Pressable>
  );
};

export const App = () => (
  <WithComponents overrides={{ ThreadListItem: CompactThreadRow }}>
    {/* the rest of the app */}
  </WithComponents>
);

The context also holds the thread's channel, the draftMessage when the thread's composer has unsent content, and the formatted dateString. ThreadListComponent, ThreadListItemMessagePreview, ThreadListUnreadBanner, ThreadListEmptyPlaceholder, ThreadListLoadingIndicator and ThreadListLoadingMoreIndicator are override keys too. The Custom thread list UI cookbook builds a whole list.

Showing the unread thread count

client.threads.state.unreadThreadCount holds the number of threads with unread replies, for a badge on a tab:

import { Text } from "react-native";
import type { ThreadManagerState } from "stream-chat";
import { useChatContext, useStateStore } from "stream-chat-react-native";

const unreadThreadsSelector = (state: ThreadManagerState) => ({
  unreadThreadCount: state.unreadThreadCount,
});

export const UnreadThreadsBadge = () => {
  const { client } = useChatContext();
  const { unreadThreadCount } = useStateStore(
    client.threads.state,
    unreadThreadsSelector,
  );

  if (!unreadThreadCount) return null;

  return <Text>{unreadThreadCount}</Text>;
};

Reference