Read-receipt avatars

This UI cookbook shows read receipts in the message list as member avatars: a small avatar under each member's last read message, which moves down as the member reads on. The sent, delivered and read marks next to the time are a different UI, which the Status indicators UI cookbook changes. Where read positions come from is explained in Read state, unread and receipts.

Note:

What you'll build

  • A small avatar under each member's last read message
  • Avatars that move down as members read on

Approach: Replace one part. Choosing where to start explains the approaches.

Best practices

  • Read the avatars from channel.messageReceiptsTracker.snapshotStore. Its readersByMessageId lists each member once, at the message their read position is on, which is where the avatar goes.
  • Don't use useMessageReadData() for this. It lists everyone who has read the message, so a member who read the newest message would show under every message above it too.
  • Leave the user out. The tracker counts the user's own read position as well.
  • Keep the default message view and add the row after it, so the bubble, the actions and the status keep working as they do.
  • Show the row only in the channel's list. Read positions belong to the channel, not to a thread.
  • Subscribe in the avatars row and not in the wrapper around the message view, so a read re-renders the rows and not the messages.

Add the avatars under each message

ReadReceiptAvatars reads the readers of the message it renders under, and MessageWithReadReceipts puts it after the SDK's MessageItemView. The MessageItemView key then renders that pair for every message:

src/ReadReceiptAvatars.tsx
import type { PropsWithChildren } from "react";
import { StyleSheet, View } from "react-native";
import type { MessageReceiptsSnapshot } from "stream-chat";
import {
  MessageItemView,
  UserAvatar,
  useChannel,
  useChatContext,
  useMessageContext,
  useStateStore,
  useThreadContext,
  WithComponents,
} from "stream-chat-react-native";

// The tracker groups members by the message their read position is on.
const readersSelector = ({ readersByMessageId }: MessageReceiptsSnapshot) => ({
  readersByMessageId,
});

/** The avatars of the other members whose last read message is this one. */
const ReadReceiptAvatars = () => {
  const channel = useChannel();
  const { client } = useChatContext();
  const { message } = useMessageContext();
  const { thread } = useThreadContext();
  const { readersByMessageId } = useStateStore(
    channel.messageReceiptsTracker.snapshotStore,
    readersSelector,
  );

  // Read positions belong to the channel's list, so a thread shows none.
  if (thread) return null;

  const readers = (readersByMessageId[message.id] ?? []).filter(
    (user) => user.id !== client.userId,
  );
  if (!readers.length) return null;

  return (
    <View style={styles.row}>
      {readers.map((user) => (
        <UserAvatar key={user.id} size="xs" user={user} />
      ))}
    </View>
  );
};

/** The SDK's message view, with the reader avatars under it. */
const MessageWithReadReceipts = () => (
  <View>
    <MessageItemView />
    <ReadReceiptAvatars />
  </View>
);

// Declared once, so WithComponents gets the same object on every render.
const overrides = { MessageItemView: MessageWithReadReceipts };

export const WithReadReceiptAvatars = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);

const styles = StyleSheet.create({
  row: {
    flexDirection: "row",
    gap: 4,
    justifyContent: "flex-end",
    paddingHorizontal: 16,
    paddingVertical: 2,
  },
});

Wrap the channel screen in WithReadReceiptAvatars:

src/ReadReceiptsChannel.tsx
import type { Channel as StreamChannel } from "stream-chat";
import {
  Channel,
  MessageComposer,
  MessageList,
} from "stream-chat-react-native";
import { WithReadReceiptAvatars } from "./ReadReceiptAvatars";

export const ReadReceiptsChannel = ({
  channel,
}: {
  channel: StreamChannel;
}) => (
  <WithReadReceiptAvatars>
    <Channel channel={channel}>
      <MessageList />
      <MessageComposer />
    </Channel>
  </WithReadReceiptAvatars>
);
Small avatars of Cara, Dan and Bob under the last message each of them has read on iOS
Small avatars of Cara, Dan and Bob under the last message each of them has read on Android

How it works

Info:

Example

Three members have read a group chat. One of them stopped at the user's question and the other two read up to the newest message. The first member's avatar sits under the question, and the other two sit under the newest message. When the first member opens the channel and reads on, their avatar moves down to the newest message.

  1. channel.messageReceiptsTracker keeps every member's read position: the last message they read, built from channel.state.read. Its snapshotStore publishes readersByMessageId, which maps a message id to the members whose position is on that message. A member appears under one message only.
  2. ReadReceiptAvatars subscribes to that store with useStateStore(), so it re-renders when a member reads further. Their avatar then leaves the old message and shows under the new one.
  3. useChannel() returns the channel the message list belongs to, and useMessageContext() the message being rendered. client.userId filters the user out, since the user's own read position is on the newest message they saw.
  4. Message renders the MessageItemView key for each message. MessageWithReadReceipts renders the SDK's MessageItemView unchanged and adds the row after it. The message menu moves the whole pair, the row included, while it's open.
  5. useThreadContext().thread is set inside a Thread. Replies have no read positions of their own, so the row isn't rendered there.

The MessageStatus marks of the default view stay on the user's own messages. To show who read a message instead of where each member stopped, see the Message Info sheet in Status indicators.

Read more