Message UI

Message UI is one of the main building blocks of the chat application. Designing proper message UI is no easy feat and that’s why our SDK comes with a pre-built component (MessageSimple) which is packed with functionality and is easy to customize through our CSS variables or component overrides (ComponentContext).

In this guide, we’ll build a simplified custom message UI component combining pre-built and other completely custom components.

Message Text and Avatars

Let’s begin with the simplest form the message UI can take: rendering the raw message text.

import { useMessageContext, Channel } from 'stream-chat-react';

const CustomMessageUi = () => {
  const { message } = useMessageContext();

  return <div data-message-id={message.id}>{message.text}</div>;
};

Message UI and all of its children can access MessageContext in which it’s wrapped and therefore can call the useMessageContext hook accessing all of the message-related information and functions alike.

To see our changes we’ll need to pass this component down to either Channel or MessageList (VirtualizedMessageList) components as a Message prop.

<Channel Message={CustomMessageUi}>...</Channel>

You can see that all the messages are now on one side and we have no idea who’s the message coming from, let’s adjust that with the help of some CSS, and to render the name of the sender we’ll need to access user property of the message object.

Our message will be on the right and the message of the other senders will be on the left side of the screen.

import { useMessageContext, Channel } from 'stream-chat-react';

const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ['custom-message-ui'];

  if (isMyMessage()) {
    messageUiClassNames.push('custom-message-ui--mine');
  } else {
    messageUiClassNames.push('custom-message-ui--other');
  }

  return (
    <div className={messageUiClassNames.join(' ')} data-message-id={message.id}>
      <strong className='custom-message-ui__name'>{message.user?.name || message.user?.id}</strong>
      <span>{message.text}</span>
    </div>
  );
};

Now this already looks way better than the initial version but we can do better - let’s switch from names to avatars using a pre-built Avatar component to make the UI slightly friendlier.

import { Avatar, useMessageContext, Channel } from 'stream-chat-react';

const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ['custom-message-ui'];

  if (isMyMessage()) {
    messageUiClassNames.push('custom-message-ui--mine');
  } else {
    messageUiClassNames.push('custom-message-ui--other');
  }

  return (
    <div className={messageUiClassNames.join(' ')} data-message-id={message.id}>
      <Avatar image={message.user?.image} name={message.user?.name || message.user?.id} />
      <span className='custom-message-ui__text'>{message.text}</span>
    </div>
  );
};

Our message UI looks pretty good now but what if the text of a message becomes more complex? Let’s say someone sends a link to a site or mentions some other user. In the current state, our UI would display this in plaintext and none of it would be interactive.

Let’s enhance this behavior by using pre-built MessageText which uses renderText internally and that’ll transform all of our links, mention, and certain Markdown syntax to interactive and neat-looking elements.

import { Avatar, MessageText, useMessageContext, Channel } from 'stream-chat-react';

const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ['custom-message-ui'];

  if (isMyMessage()) {
    messageUiClassNames.push('custom-message-ui--mine');
  } else {
    messageUiClassNames.push('custom-message-ui--other');
  }

  return (
    <div className={messageUiClassNames.join(' ')} data-message-id={message.id}>
      <Avatar image={message.user?.image} name={message.user?.name || message.user?.id} />
      <MessageText />
    </div>
  );
};

While the mentioned user is being highlighted there’s no default pointer event attached to the highlighted element, see the Mentions Actions guide for more information.

Metadata

So far we’ve covered avatars and proper text rendering but the UI still feels a bit empty. Each message has a lot of extra information which can be beneficial to the end users. Let’s add the creation date, “edited” flag, and delivery/read status information to our message UI.

import { Avatar, MessageText, useMessageContext, useChatContext, Channel } from 'stream-chat-react';

const statusIconMap = {
  received: '✅',
  receivedAndRead: '👁️',
  sending: '🛫',
  unknown: '❓',
};

const CustomMessageUiMetadata = () => {
  const {
    message: {
      created_at: createdAt,
      message_text_updated_at: messageTextUpdatedAt,
      status = 'unknown',
    },
    readBy = [],
  } = useMessageContext();
  const { client } = useChatContext();

  const [firstUser] = readBy;

  const receivedAndRead = readBy.length > 1 || (firstUser && firstUser.id !== client.user?.id);

  return (
    <div className='custom-message-ui__metadata'>
      <div className='custom-message-ui__metadata-created-at'>{createdAt?.toLocaleString()}</div>
      <div className='custom-message-ui__metadata-read-status'>
        {receivedAndRead
          ? statusIconMap.receivedAndRead
          : statusIconMap[status as keyof typeof statusIconMap] ?? statusIconMap.unknown}
      </div>
      {messageTextUpdatedAt && (
        <div className='custom-message-ui__metadata-edited-status' title={messageTextUpdatedAt}>
          Edited
        </div>
      )}
    </div>
  );
};

const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ['custom-message-ui'];

  if (isMyMessage()) {
    messageUiClassNames.push('custom-message-ui--mine');
  } else {
    messageUiClassNames.push('custom-message-ui--other');
  }

  return (
    <div className={messageUiClassNames.join(' ')} data-message-id={message.id}>
      <div className='custom-message-ui__body'>
        <Avatar image={message.user?.image} name={message.user?.name || message.user?.id} />
        <MessageText />
      </div>
      <CustomMessageUiMetadata />
    </div>
  );
};

Message grouping is being managed automatically by the SDK and each parent element (which holds our message UI) receives an appropriate class name based on which we can adjust our rules to display metadata elements only when it’s appropriate to make our UI look less busy.

.custom-message-ui__metadata {
  /* removed-line */
  display: flex;
  /* added-line */
  display: none;
  font-size: x-small;
  align-items: baseline;
}

/* added-block-start */
.str-chat__li--bottom .custom-message-ui__metadata,
.str-chat__li--single .custom-message-ui__metadata {
  display: flex;
}
/* added-block-end */

You can utilize MessageContext properties firstOfGroup, endOfGroup, and groupedByUser if you use VirtualizedMessageList for conditional metadata rendering. These properties are not available in regular MessageList.

The SDK also comes with MessageStatus and MessageTimestamp components which you can use to replace our custom-made ones. These components come with some extra logic but essentially provide an almost identical amount of utility for the end users so we won’t be covering the replacement in this guide.

Message Actions

Up to this point we’ve covered mostly the presentational part of our message UI and apart from the mentions and links, there’s not much the end users can interact with. Obviously - the SDK offers so much more so in this section of the guide we’ll explain how to add and enable message actions (deleting, replies, pinning, etc.) and reactions.

At the very beginning of this guide, we’ve mentioned that MessageContext provides information and functions related to a specific message. So to implement a subset of message actions we’ll need to access handleDelete, handlePin, handleFlag, and handleThread functions which we can attach to the action buttons of our CustomMessageUiActions component.

const statusIconMap = {
  received: '✅',
  receivedAndRead: '👁️',
  sending: '🛫',
  unknown: '❓',
};

const CustomMessageUiActions = () => {
  const {
    handleDelete,
    handleFlag,
    handleOpenThread,
    handlePin,
    message,
    threadList,
  } = useMessageContext();

  if (threadList) return null;

  return (
    <div className='custom-message-ui__actions'>
      <div className='custom-message-ui__actions-group'>
        <button onClick={handlePin} title={message.pinned ? 'Unpin' : 'Pin'}>
          {message.pinned ? '📍' : '📌'}
        </button>
        <button onClick={handleDelete} title='Delete'>
          🗑️
        </button>
        <button onClick={handleOpenThread} title='Open thread'>
          ↩️
        </button>
        <button onClick={handleFlag} title='Flag message'>
          🚩
        </button>
      </div>
    </div>
  );
};

const CustomMessageUiMetadata = () => {
  const {
    message: {
      created_at: createdAt,
      message_text_updated_at: messageTextUpdatedAt,
      status = 'unknown',
    },
    readBy = [],
  } = useMessageContext();
  const { client } = useChatContext();

  const [firstUser] = readBy;

  const receivedAndRead = readBy.length > 1 || (firstUser && firstUser.id !== client.user?.id);

  return (
    <div className='custom-message-ui__metadata'>
      <div className='custom-message-ui__metadata-created-at'>{createdAt?.toLocaleString()}</div>
      <div className='custom-message-ui__metadata-read-status'>
        {receivedAndRead
          ? statusIconMap.receivedAndRead
          : statusIconMap[status as keyof typeof statusIconMap] ?? statusIconMap.unknown}
      </div>
      {messageTextUpdatedAt && (
        <div className='custom-message-ui__metadata-edited-status' title={messageTextUpdatedAt}>
          Edited
        </div>
      )}
    </div>
  );
};

const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ['custom-message-ui'];

  if (isMyMessage()) {
    messageUiClassNames.push('custom-message-ui--mine');
  } else {
    messageUiClassNames.push('custom-message-ui--other');
  }

  return (
    <div className={messageUiClassNames.join(' ')} data-message-id={message.id}>
      <div className='custom-message-ui__body'>
        <Avatar image={message.user?.image} name={message.user?.name || message.user?.id} />
        <MessageText />
      </div>
      <CustomMessageUiMetadata />
      <CustomMessageUiActions />
    </div>
  );
};

Now that we’ve enabled some actions we’ll also need to cover certain UI parts that should reflect the latest message state which weren’t relevant before. You can see that we’ve already covered the message’s “pinned” state by accessing the pinned property of the message object and rendering the appropriate icon when this property is set to true. See the Pin Indicator guide for more customization options. Let’s explore other customizations needed for a complete message UI.

The following code samples contain only the code related to the appropriate components, if you’re following along you can copy and add the following examples to whatever you have created up until now. The whole example is at the bottom of this guide.

Thread and Reply Count

First - upon opening a thread and replying to a message, the message’s property reply_count changes; let’s add the count indicator button beside the rest of the metadata elements so the end users can access the thread from two places.

const CustomMessageUiMetadata = () => {
  const {
    message: {
      created_at: createdAt,
      message_text_updated_at: messageTextUpdatedAt,
      // added-line
      reply_count: replyCount = 0,
      status = 'unknown',
    },
    readBy = [],
    handleOpenThread,
  } = useMessageContext();
  const { client } = useChatContext();

  const [firstUser] = readBy;

  const receivedAndRead = readBy.length > 1 || (firstUser && firstUser.id !== client.user?.id);

  return (
    <div className='custom-message-ui__metadata'>
      <div className='custom-message-ui__metadata-created-at'>{createdAt?.toLocaleString()}</div>
      <div className='custom-message-ui__metadata-read-status'>
        {receivedAndRead
          ? statusIconMap.receivedAndRead
          : statusIconMap[status as keyof typeof statusIconMap] ?? statusIconMap.unknown}
      </div>
      {messageTextUpdatedAt && (
        <div className='custom-message-ui__metadata-edited-status' title={messageTextUpdatedAt}>
          Edited
        </div>
      )}
      // added-block-start
      {replyCount > 0 && (
        <button className='custom-message-ui__metadata-reply-count' onClick={handleOpenThread}>
          <span>
            {replyCount} {replyCount > 1 ? 'replies' : 'reply'}
          </span>
        </button>
      )}
      // added-block-end
    </div>
  );
};

Deleted Message

Since now we’re also able to soft delete our own messages we’ll need to render a slightly different UI to handle such a case. Let’s add a condition where we check whether the deleted_at property of the message object exists and if it does we’ll simply fall back to “message deleted” text.

const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ['custom-message-ui'];

  if (isMyMessage()) {
    messageUiClassNames.push('custom-message-ui--mine');
  } else {
    messageUiClassNames.push('custom-message-ui--other');
  }

  return (
    <div className={messageUiClassNames.join(' ')} data-message-id={message.id}>
      // added-block-start
      {message.deleted_at && (
        <div className='custom-message-ui__body'>This message has been deleted...</div>
      )}
      {!message.deleted_at && (
        <>
          <div className='custom-message-ui__body'>
            <Avatar image={message.user?.image} name={message.user?.name || message.user?.id} />
            <MessageText />
          </div>
          <CustomMessageUiMetadata />
          <CustomMessageUiActions />
        </>
      )}
      // added-block-end
    </div>
  );
};

Reactions

With message actions in place we’ve made our message UI slightly more interactive but there’s still a place for improvement. In this section of the guide we’ll create a simple selector consisting of two reactions (thumbs up and down) and to display them we’ll reuse and slightly modify the ReactionsList component provided by the SDK. Let’s begin by defining customReactionOptions (see more in the Reactions Customization guide) and by passing them down to a reactionOptions prop of a Channel component.

const customReactionOptions = [
  { name: 'Thumbs up', type: '+1', Component: () => <>👍</> },
  { name: 'Thumbs down', type: '-1', Component: () => <>👎</> },
];

<Channel Message={CustomMessageUi} reactionOptions={customReactionOptions}>
  ...
</Channel>;

And now that’s done we can continue by extending our CustomMessageUiActions component using these newly defined options.

const CustomMessageUiActions = () => {
  const {
    handleDelete,
    handleFlag,
    handleOpenThread,
    handlePin,
    // added-line
    handleReaction,
    message,
    threadList,
  } = useMessageContext();

  // added-line
  const { reactionOptions } = useComponentContext();

  if (threadList) return null;

  return (
    <div className='custom-message-ui__actions'>
      <div className='custom-message-ui__actions-group'>
        <button onClick={handlePin} title={message.pinned ? 'Unpin' : 'Pin'}>
          {message.pinned ? '📍' : '📌'}
        </button>
        <button onClick={handleDelete} title='Delete'>
          🗑️
        </button>
        <button onClick={handleOpenThread} title='Open thread'>
          ↩️
        </button>
        <button onClick={handleFlag} title='Flag message'>
          🚩
        </button>
      </div>
      // added-block-start
      <div className='custom-message-ui__actions-group'>
        {reactionOptions.map(({ Component, name, type }) => (
          <button key={type} onClick={(e) => handleReaction(type, e)} title={`React with: ${name}`}>
            <Component />
          </button>
        ))}
      </div>
      // added-block-end
    </div>
  );
};

Finally, we can add the ReactionsList component to our CustomMessageUi component and adjust the styles accordingly.

Complete Example

import {
  Avatar,
  MessageText,
  ReactionsList,
  useMessageContext,
  useChatContext,
  Channel,
} from 'stream-chat-react';

const customReactionOptions = [
  { name: 'Thumbs up', type: '+1', Component: () => <>👍</> },
  { name: 'Thumbs down', type: '-1', Component: () => <>👎</> },
];

const statusIconMap = {
  received: '✅',
  receivedAndRead: '👁️',
  sending: '🛫',
  unknown: '❓',
};

const CustomMessageUiActions = () => {
  const {
    handleDelete,
    handleFlag,
    handleOpenThread,
    handlePin,
    handleReaction,
    message,
    threadList,
  } = useMessageContext();

  const { reactionOptions } = useComponentContext();

  if (threadList) return null;

  return (
    <div className='custom-message-ui__actions'>
      <div className='custom-message-ui__actions-group'>
        <button onClick={handlePin} title={message.pinned ? 'Unpin' : 'Pin'}>
          {message.pinned ? '📍' : '📌'}
        </button>
        <button onClick={handleDelete} title='Delete'>
          🗑️
        </button>
        <button onClick={handleOpenThread} title='Open thread'>
          ↩️
        </button>
        <button onClick={handleFlag} title='Flag message'>
          🚩
        </button>
      </div>
      <div className='custom-message-ui__actions-group'>
        {reactionOptions.map(({ Component, name, type }) => (
          <button key={type} onClick={(e) => handleReaction(type, e)} title={`React with: ${name}`}>
            <Component />
          </button>
        ))}
      </div>
    </div>
  );
};

const CustomMessageUiMetadata = () => {
  const {
    message: {
      created_at: createdAt,
      message_text_updated_at: messageTextUpdatedAt,
      reply_count: replyCount = 0,
      status = 'unknown',
    },
    readBy = [],
    handleOpenThread,
  } = useMessageContext();
  const { client } = useChatContext();

  const [firstUser] = readBy;

  const receivedAndRead = readBy.length > 1 || (firstUser && firstUser.id !== client.user?.id);

  return (
    <div className='custom-message-ui__metadata'>
      <div className='custom-message-ui__metadata-created-at'>{createdAt?.toLocaleString()}</div>
      <div className='custom-message-ui__metadata-read-status'>
        {receivedAndRead
          ? statusIconMap.receivedAndRead
          : statusIconMap[status as keyof typeof statusIconMap] ?? statusIconMap.unknown}
      </div>
      {messageTextUpdatedAt && (
        <div className='custom-message-ui__metadata-edited-status' title={messageTextUpdatedAt}>
          Edited
        </div>
      )}
      {replyCount > 0 && (
        <button className='custom-message-ui__metadata-reply-count' onClick={handleOpenThread}>
          <span>
            {replyCount} {replyCount > 1 ? 'replies' : 'reply'}
          </span>
        </button>
      )}
    </div>
  );
};

const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ['custom-message-ui'];

  if (isMyMessage()) {
    messageUiClassNames.push('custom-message-ui--mine');
  } else {
    messageUiClassNames.push('custom-message-ui--other');
  }

  return (
    <div className={messageUiClassNames.join(' ')} data-message-id={message.id}>
      {message.deleted_at && (
        <div className='custom-message-ui__body'>This message has been deleted...</div>
      )}
      {!message.deleted_at && (
        <>
          <div className='custom-message-ui__body'>
            <Avatar image={message.user?.image} name={message.user?.name || message.user?.id} />
            <MessageText />
          </div>
          <CustomMessageUiMetadata />
          <CustomMessageUiActions />
          // added-line
          <ReactionsList />
        </>
      )}
    </div>
  );
};

Attachments

The topic of attachments is pretty substantial by itself, so we won’t be covering it in this guide. Please, refer to the source code of our default MessageSimple for details on implementation and see the Custom Attachment guide for more customization options.

Read More

Functionalities relevant to the Message UI that are also not covered in this guide:

© Getstream.io, Inc. All Rights Reserved.