Message info

This UI cookbook builds a Message info screen. A Message Info action in the menu of the user's own messages opens a screen of your navigator that lists who has read the message and who has only received it, each with a time, and keeps the lists current while it is open. The receipts come from the channel's messageReceiptsTracker, which Read state, unread and receipts explains. The Status indicators UI cookbook builds a sheet inside Channel from the receipts hooks. This one is a screen of its own, reads the tracker directly so it needs no Channel around it, and shows when each member read or received the message.

Note:

What you'll build

  • a Message info screen with the members who read and received a message
  • a Message Info action in the menu of the user's own messages

Approach: Compose. Choosing where to start explains the approaches.

Best practices

  • Ask channel.messageReceiptsTracker who read or received a message. It turns every member's read state into answers per message, so the screen never compares timestamps itself.
  • Subscribe to channel.messageReceiptsTracker.snapshotStore and to channel.state, so the lists and the times update while the screen is open.
  • Keep only the channel's cid and the message's id in the route params, and read the message from channel.messagePaginator. A copy taken when the screen opens never shows an edit.
  • Leave the user out of both lists. The tracker counts the user's own read position too. Sending a message doesn't move it; it moves when the message list marks the channel read, so the user shows up among the readers after that.
  • Offer the action only on the user's own sent messages in the channel's list, and only when the channel has read events. Threads have no tracker of their own, and delivery isn't reported for thread replies.

Build the screen

MessageInfo reads three stores of the channel, and the screen shows what they hold:

  • channel.messagePaginator.state holds the loaded messages, so items.find() returns the message as the channel holds it now.
  • MessageReceiptsTracker.readersForMessage() returns the members who have read the message, and MessageReceiptsTracker.deliveredNotReadForMessage() the members who have received it and not read it. Both take the message's id and created_at.
  • channel.state holds each member's read state in read. last_read is the time of the Read by row and last_delivered_at the time of the Delivered to row. Both are nanosecond timestamps, so convertTimestampToDate() turns them into the date getDateString() formats, with the key the channel list uses for its rows.

The tracker publishes a snapshot to snapshotStore each time a member's read or delivery position moves. The revision the screen reads from it is a dependency of the lists, so they are worked out again then.

src/MessageInfoScreen.tsx
import { useMemo } from "react";
import { SectionList, StyleSheet, Text, View } from "react-native";
import type { SectionListData } from "react-native";
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import { convertTimestampToDate } from "stream-chat";
import type {
  Channel as StreamChannel,
  ChannelStateData,
  LocalMessage,
  MessageReceiptsSnapshot,
  PaginatorState,
  TimestampNS,
  UserResponse,
} from "stream-chat";
import {
  getDateString,
  useChatContext,
  useStateStore,
  useTheme,
  useTranslationContext,
  UserAvatar,
} from "stream-chat-react-native";

export type RootStackParamList = {
  Channel: { cid: string };
  MessageInfo: { cid: string; messageId: string };
};

// Declared at module scope, so the subscriptions aren't recreated on every render.
const revisionSelector = ({ revision }: MessageReceiptsSnapshot) => ({
  revision,
});
const readSelector = ({ read }: ChannelStateData) => ({ read });
const itemsSelector = ({ items }: PaginatorState<LocalMessage>) => ({ items });

type ReceiptSection = {
  data: UserResponse[];
  emptyText: string;
  timeOf: (userId: string) => TimestampNS | undefined;
  title: string;
};

/** Who has read the message, and who has received it and not read it yet. */
const useMessageInfo = (channel: StreamChannel, messageId: string) => {
  const { client } = useChatContext();
  // The tracker publishes a snapshot each time a member's read or delivery position moves.
  const { revision } = useStateStore(
    channel.messageReceiptsTracker.snapshotStore,
    revisionSelector,
  );
  const { read } = useStateStore(channel.state, readSelector);
  const { items } = useStateStore(
    channel.messagePaginator.state,
    itemsSelector,
  );
  // The message as the channel holds it now, so an edit shows while the screen is open.
  const message = items?.find(({ id }) => id === messageId);

  const sections = useMemo((): ReceiptSection[] => {
    if (typeof message?.created_at !== "number") return [];
    const ref = { msgId: message.id, timestamp: message.created_at };
    const { messageReceiptsTracker } = channel;
    // The tracker counts the user's own position too, so the lists leave the user out.
    const others = (users: UserResponse[]) =>
      users.filter(({ id }) => id !== client.userId);

    return [
      {
        data: others(messageReceiptsTracker.readersForMessage(ref)),
        emptyText: "No one has read it yet.",
        timeOf: (userId) => read[userId]?.last_read,
        title: "Read by",
      },
      {
        data: others(messageReceiptsTracker.deliveredNotReadForMessage(ref)),
        emptyText: "No one else has received it yet.",
        timeOf: (userId) => read[userId]?.last_delivered_at,
        title: "Delivered to",
      },
    ];
    // The tracker answers from its latest snapshot, so each new `revision` can change the lists.
  }, [channel, client, message, read, revision]);

  return { message, sections };
};

const ReceiptRow = ({
  timestamp,
  user,
}: {
  timestamp?: TimestampNS;
  user: UserResponse;
}) => {
  const { t, tDateTimeParser } = useTranslationContext();
  const {
    theme: { semantics },
  } = useTheme();
  const time = useMemo(
    () =>
      getDateString({
        messageCreatedAt: convertTimestampToDate(timestamp),
        t,
        tDateTimeParser,
        timestampTranslationKey: "timestamp.ChannelPreviewStatus",
      }),
    [timestamp, t, tDateTimeParser],
  );

  return (
    <View style={styles.row}>
      <UserAvatar size="lg" user={user} />
      <Text style={[styles.name, { color: semantics.textPrimary }]}>
        {user.name ?? user.id}
      </Text>
      <Text style={{ color: semantics.textSecondary }}>{time}</Text>
    </View>
  );
};

type SectionInfo = { section: SectionListData<UserResponse, ReceiptSection> };

const MessageInfo = ({
  channel,
  messageId,
}: {
  channel: StreamChannel;
  messageId: string;
}) => {
  const { message, sections } = useMessageInfo(channel, messageId);
  const {
    theme: { semantics },
  } = useTheme();

  if (!message) {
    return (
      <Text style={[styles.note, { color: semantics.textSecondary }]}>
        This message isn't available.
      </Text>
    );
  }

  return (
    <SectionList<UserResponse, ReceiptSection>
      ListHeaderComponent={
        <Text style={[styles.message, { color: semantics.textPrimary }]}>
          {message.text}
        </Text>
      }
      keyExtractor={(user) => user.id}
      renderItem={({ item, section }) => (
        <ReceiptRow timestamp={section.timeOf(item.id)} user={item} />
      )}
      renderSectionFooter={({ section }: SectionInfo) =>
        section.data.length ? null : (
          <Text style={[styles.note, { color: semantics.textSecondary }]}>
            {section.emptyText}
          </Text>
        )
      }
      renderSectionHeader={({ section }: SectionInfo) => (
        <Text
          style={[
            styles.heading,
            {
              backgroundColor: semantics.backgroundCoreApp,
              color: semantics.textPrimary,
            },
          ]}
        >
          {section.title}
        </Text>
      )}
      sections={sections}
    />
  );
};

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

  if (!channel) return null;

  return <MessageInfo channel={channel} messageId={messageId} />;
};

const styles = StyleSheet.create({
  heading: { fontWeight: "700", paddingHorizontal: 16, paddingVertical: 8 },
  message: { fontSize: 16, padding: 16 },
  name: { flex: 1, fontWeight: "600" },
  note: { paddingHorizontal: 16, paddingVertical: 8 },
  row: {
    alignItems: "center",
    flexDirection: "row",
    gap: 12,
    paddingHorizontal: 16,
    paddingVertical: 8,
  },
});
The Message info screen with the message text, Read by Cara and Dan, and Delivered to Bob, each with a time, on iOS
The Message info screen with the message text, Read by Cara and Dan, and Delivered to Bob, each with a time, on Android

Open it from the message menu

The channel screen passes messageActions to Channel. It starts from the SDK's list for the message, and adds a Message Info action that pushes the screen. When the menu opens, the SDK builds the list for that message, so the checks run only for a message the user opens the menu on. See Custom message actions for more on building actions.

src/ChannelScreen.tsx
import { useCallback } from "react";
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import {
  Channel,
  messageActions as defaultMessageActions,
  MessageComposer,
  MessageList,
  useChatContext,
  useComponentsContext,
  useTheme,
} from "stream-chat-react-native";
import type { MessageActionsProp } from "stream-chat-react-native";
import type { RootStackParamList } from "./MessageInfoScreen";

export const ChannelScreen = ({
  navigation,
  route,
}: NativeStackScreenProps<RootStackParamList, "Channel">) => {
  const { client } = useChatContext();
  const { icons } = useComponentsContext();
  const {
    theme: { semantics },
  } = useTheme();
  const channel = client.channelManager.get(route.params.cid);

  const messageActions = useCallback<MessageActionsProp>(
    (params) => {
      const actions = defaultMessageActions(params);
      // Receipts exist for the user's own messages that were sent, in a channel with read events.
      // Threads have no receipts of their own, so a thread's replies have no info.
      if (
        !params.isMyMessage ||
        params.isThreadMessage ||
        params.error ||
        params.message.status !== "received" ||
        !params.ownCapabilities.readEvents
      ) {
        return actions;
      }

      return [
        ...actions,
        {
          action: () =>
            navigation.navigate("MessageInfo", {
              cid: params.message.cid,
              messageId: params.message.id,
            }),
          actionType: "messageInfo",
          icon: (
            <icons.InfoTooltip
              height={20}
              stroke={semantics.textSecondary}
              width={20}
            />
          ),
          title: "Message Info",
          type: "standard",
        },
      ];
    },
    [icons, navigation, semantics.textSecondary],
  );

  if (!channel) return null;

  return (
    <Channel channel={channel} messageActions={messageActions}>
      <MessageList />
      <MessageComposer />
    </Channel>
  );
};
The message menu of the user's own message with a Message Info item on iOS
The message menu of the user's own message with a Message Info item on Android

Register MessageInfoScreen as the MessageInfo screen of the same stack, next to the channel screen. To present it as a sheet instead of a pushed screen, give the screen presentation: "modal"; on iOS a native modal is a separate window, which Chat in modals explains.

How it works

Info:

Example

The user sends "Great, I will bring the blanket and lemonade." in Picnic plan, with Bob, Cara and Dan:

  1. Dan's, Bob's and Cara's apps receive the message and report it delivered. The server sends message.delivered, which moves their last_delivered_at in channel.state.read.
  2. Bob and Cara open the channel. Their last_read moves past the message.
  3. The user long-presses the message and picks Message Info. The menu closes, and the stack pushes MessageInfo with the channel's cid and the message's id.
  4. MessageReceiptsTracker.readersForMessage() returns Bob and Cara, and the user as well if the message list has marked the channel read since the message was sent. The screen leaves the user out either way. MessageReceiptsTracker.deliveredNotReadForMessage() returns Dan.
  5. Dan opens the channel later. His read position moves, the tracker publishes a new snapshot, and Dan moves from Delivered to into Read by while the screen is open.
  • The tracker compares the message's created_at with each member's read and delivered positions, so a member who read a later message counts as having read this one too.
  • The time under Read by is last_read: when the member last read the channel, which can be later than when they first saw this message. The server keeps one read position per member and channel, not a time per message. Delivered to shows last_delivered_at the same way.
  • With delivery events off for the channel type, nobody is ever only delivered: delivered follows read, and Delivered to stays empty. Turning it on or off lists the switches.
  • Only loaded messages resolve. A member's position that the server reports as a time, without a message id, counts only when a loaded message is that old. See Limits.
  • The screen follows channel.messagePaginator, so an edited text shows while it is open, and a message that leaves the loaded messages shows "This message isn't available."

Read more