StreamMessageItem

StreamMessageItem renders a single message in the list, including its text, attachments, reactions, reply counts, sender avatar, and timestamps. It is built around a component factory pattern. The default item is composed of named sub-components. The whole item can be replaced via the messageItem factory slot; quoted-message rendering via quotedMessage; and the leading (avatar), header (pinned/reminder annotations), and footer (username, timestamp, status) can each be replaced independently via the messageLeading, messageHeader, and messageFooter factory slots. See the pub.dev documentation for the full API reference.

Background

StreamMessageItem is a thin shell widget that renders a single message, including its attachments, reactions, sender avatar, timestamp, and other metadata.

The widget is built around a centralized component factory pattern. This enables app-wide message customization without repeating configuration on every widget. The default item is composed of named sub-components. Each of the following factory slots can be replaced independently without touching the whole item:

  • messageItem — replace the entire message widget
  • quotedMessage — replace quoted-message rendering
  • messageLeading — replace the author avatar shown beside the bubble
  • messageHeader — replace the pinned/reminder annotations above the bubble
  • messageFooter — replace the username, timestamp, and sending-status row below the bubble

Key types:

  • StreamMessageItem — thin shell, resolves the StreamComponentFactory and delegates rendering
  • StreamMessageItemProps — plain data class holding all configuration, supports copyWith()
  • DefaultStreamMessageItem — default rendering implementation, composes the sub-components below
  • StreamMessageLeading — author avatar beside the bubble; collapses automatically when message.user is null
  • StreamMessageContent — bubble, attachments, text, reactions, thread replies
  • StreamMessageHeader — pinned/reminder annotations displayed above the bubble
  • StreamMessageFooter — username, timestamp, sending status, edited indicator
  • StreamMessageReactions — clustered reaction chips around the bubble
  • StreamMessageText — markdown-rendered message text

Sub-Components

DefaultStreamMessageItem is composed of named sub-components:

Sub-componentDescription
DefaultStreamMessageItemTop-level default renderer; composes all sub-components
StreamMessageLeadingAuthor avatar beside the bubble; collapses when no user
StreamMessageContentBubble, attachments, text, reactions, thread replies
StreamMessageHeaderPinned/reminder annotations above the bubble
StreamMessageFooterUsername, timestamp, sending status, edited indicator
StreamMessageReactionsClustered reaction chips around the bubble
StreamMessageTextMarkdown-rendered message text
StreamMessageDeletedDeleted message placeholder
StreamMessageSendingStatusDelivery status icon

Basic Example

StreamMessageItem is primarily used inside StreamMessageListView. The messageBuilder callback receives StreamMessageItemProps as defaultProps — a plain data class holding the resolved configuration — and expects a widget in return:

class ChannelPage extends StatelessWidget {
  const ChannelPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: StreamMessageListView(
        messageBuilder: (context, message, defaultProps) {
          // Build the default widget (goes through component factory)
          return StreamMessageItem.fromProps(props: defaultProps);
        },
      ),
    );
  }
}

Per-List Customization

Use StreamMessageItemProps.copyWith() to adjust specific properties for a single list:

StreamMessageListView(
  messageBuilder: (context, message, defaultProps) {
    return StreamMessageItem.fromProps(
      props: defaultProps.copyWith(
        actionsBuilder: (context, actions) => [...actions, myAction],
      ),
    );
  },
),

Or replace the message widget entirely with your own:

StreamMessageListView(
  messageBuilder: (context, message, defaultProps) {
    return MyCustomMessageWidget(message: message);
  },
),

For a practical example, here is how to wrap messages with a custom swipe-to-reply gesture:

StreamMessageListView(
  messageBuilder: (context, message, defaultProps) {
    final defaultWidget = StreamMessageItem.fromProps(props: defaultProps);

    if (message.isDeleted || message.state.isFailed) return defaultWidget;

    final alignment = StreamMessageLayout.alignmentDirectionalOf(context);
    final isEnd = alignment == AlignmentDirectional.centerEnd;

    return Swipeable(
      key: ValueKey(message.id),
      direction: isEnd ? SwipeDirection.endToStart : SwipeDirection.startToEnd,
      swipeThreshold: 0.2,
      onSwiped: (_) => onReply(message),
      child: defaultWidget,
    );
  },
)

App-wide Customization

To apply customizations across the entire app, use the componentBuilders parameter on StreamChat:

StreamChat(
  client: client,
  componentBuilders: StreamComponentBuilders(
    extensions: streamChatComponentBuilders(
      messageItem: (context, props) {
        return DefaultStreamMessageItem(
          props: props.copyWith(
            actionsBuilder: (context, defaultActions) {
              return [
                ...defaultActions,
                StreamContextMenuAction(
                  leading: const Icon(Icons.star),
                  label: const Text('Favourite'),
                  onTap: () => _favourite(props.message),
                ),
              ];
            },
          ),
        );
      },
    ),
  ),
  child: const MyHomePage(),
)

For subtree-scoped overrides, wrap that subtree with StreamComponentFactory directly:

StreamComponentFactory(
  builders: StreamComponentBuilders(
    extensions: streamChatComponentBuilders(
      messageItem: (context, props) => MySpecialMessage(props: props),
    ),
  ),
  child: const ChatDetailScreen(),
)

The leading (avatar), header (annotations), and footer (username/timestamp/status) slots can each be replaced independently — you do not need to override the whole message item. Each slot receives a Props data class and has a matching Default… widget you can fall back to:

SlotProps classDefault widgetReplaces
messageLeadingStreamMessageLeadingPropsDefaultStreamMessageLeadingAuthor avatar beside the bubble
messageHeaderStreamMessageHeaderPropsDefaultStreamMessageHeaderPinned / reminder annotations above the bubble
messageFooterStreamMessageFooterPropsDefaultStreamMessageFooterUsername, timestamp, sending status row

Register any combination of slots via streamChatComponentBuilders:

StreamChat(
  client: client,
  componentBuilders: StreamComponentBuilders(
    extensions: streamChatComponentBuilders(
      // Swap just the avatar.
      messageLeading: (context, props) =>
          MyPresenceAvatar(user: props.message.user),

      // Add a custom annotation while keeping the default header.
      messageHeader: (context, props) =>
          DefaultStreamMessageHeader(props: props),

      // Replace the timestamp row entirely.
      messageFooter: (context, props) =>
          MyCustomFooter(message: props.message),
    ),
  ),
  child: MyApp(),
)

Each slot is independently overridable — none require touching messageItem.

Custom Attachment Builders

Register custom attachment builders globally via StreamChatConfigurationData:

class LocationAttachmentBuilder extends StreamAttachmentWidgetBuilder {
  @override
  bool canHandle(
    Message message,
    Map<String, List<Attachment>> attachments,
  ) {
    final locationAttachments = attachments['location'];
    return locationAttachments != null && locationAttachments.isNotEmpty;
  }

  @override
  Widget build(
    BuildContext context,
    Message message,
    Map<String, List<Attachment>> attachments,
  ) {
    final attachment = attachments['location']!.first;
    return LocationMapWidget(
      latitude: attachment.extraData['latitude'] as double,
      longitude: attachment.extraData['longitude'] as double,
    );
  }
}

StreamChat(
  client: client,
  configData: StreamChatConfigurationData(
    attachmentBuilders: [
      LocationAttachmentBuilder(),
    ],
  ),
  child: MyHomePage(),
)

Custom builders provided to attachmentBuilders are prepended to the default set automatically — you do not need to spread StreamAttachmentWidgetBuilder.defaultBuilders(...) yourself. The SDK merges your builders with the defaults per-message at render time.

You can also override attachment builders for a single message via StreamMessageItemProps.attachmentBuilders, which has the same semantics scoped to that message.

Replacing a built-in attachment type (e.g. image):

Custom builders are prepended to the default list automatically by the widget, so a builder whose canHandle matches type: 'image' takes over before the SDK's default image renderer. Here is an example that replaces the built-in single-image attachment with a custom rounded widget:

class MyImageAttachmentBuilder extends StreamAttachmentWidgetBuilder {
  @override
  bool canHandle(
    Message message,
    Map<String, List<Attachment>> attachments,
  ) {
    final images = attachments['image'];
    return images != null && images.isNotEmpty;
  }

  @override
  Widget build(
    BuildContext context,
    Message message,
    Map<String, List<Attachment>> attachments,
  ) {
    final attachment = attachments['image']!.first;
    final imageUrl = attachment.imageUrl ?? attachment.thumbUrl ?? attachment.assetUrl;
    return ClipRRect(
      borderRadius: BorderRadius.circular(12),
      child: imageUrl != null
          ? Image.network(imageUrl, fit: BoxFit.cover)
          : const SizedBox(width: 200, height: 150),
    );
  }
}

// Register globally — takes priority over the SDK default:
StreamChat(
  client: client,
  configData: StreamChatConfigurationData(
    attachmentBuilders: [
      MyImageAttachmentBuilder(),
    ],
  ),
  child: MyHomePage(),
)

To override only for a single message, use StreamMessageItemProps.attachmentBuilders in your messageBuilder:

StreamMessageListView(
  messageBuilder: (context, message, defaultProps) {
    return StreamMessageItem.fromProps(
      props: defaultProps.copyWith(
        attachmentBuilders: [
          MyImageAttachmentBuilder(),
        ],
      ),
    );
  },
)

Callbacks

StreamMessageListView(
  onMessageLinkTap: (message, url) => launchUrl(Uri.parse(url)),
  onUserMentionTap: (user) => showProfile(user),
  onQuotedMessageTap: (quotedMessage) => scrollToMessage(quotedMessage.id),
)

Theming

Theming is controlled via StreamTheme, a Flutter ThemeExtension registered in ThemeData.extensions. Use StreamMessageItemThemeData to configure structural styling (visibility, layout, bubble colors, and text):

MaterialApp(
  theme: ThemeData(
    extensions: [
      StreamTheme(
        messageItemTheme: StreamMessageItemThemeData(
          // Hide the avatar except on the bottom message of a stack
          avatarVisibility: StreamMessageLayoutProperty.resolveWith(
            (p) => switch (p.stackPosition) {
              StreamMessageStackPosition.bottom ||
              StreamMessageStackPosition.single =>
                StreamVisibility.visible,
              _ => StreamVisibility.gone,
            },
          ),
          // Always show metadata (timestamp, username)
          metadataVisibility: StreamMessageLayoutVisibility.all(
            StreamVisibility.visible,
          ),
        ),
      ),
    ],
  ),
)

For a subtree-scoped visibility override, wrap with StreamMessageItemTheme directly:

StreamMessageItemTheme(
  data: StreamMessageItemThemeData(
    padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
  ),
  child: StreamMessageListView(controller: controller),
)

Customizing Text

To change the text style for messages, configure StreamMessageItemThemeData.text inside StreamTheme. Use StreamMessageTextStyle.from for a uniform style across all messages:

MaterialApp(
  theme: ThemeData(
    extensions: [
      StreamTheme(
        messageItemTheme: StreamMessageItemThemeData(
          text: StreamMessageTextStyle.from(
            textColor: Colors.black,
          ),
        ),
      ),
    ],
  ),
)

For placement-aware styling (different style for incoming vs. outgoing messages), use StreamMessageLayoutProperty.resolveWith:

StreamTheme(
  messageItemTheme: StreamMessageItemThemeData(
    text: StreamMessageTextStyle(
      textColor: StreamMessageLayoutProperty.resolveWith((p) {
        final isEnd = p.alignment == StreamMessageAlignment.end;
        return isEnd ? Colors.white : Colors.black;
      }),
    ),
  ),
)

For advanced text rendering (for example, adding hashtag support), use the component factory to replace StreamMessageText. The hashtag example below uses the flutter_markdown package to render hashtags as tappable links:

// Register a custom StreamMessageText replacement via the component factory
class HashtagMessageText extends StatelessWidget {
  const HashtagMessageText({super.key, required this.message});

  final Message message;

  @override
  Widget build(BuildContext context) {
    final messageTheme = StreamMessageItemTheme.of(context);

    final text = _replaceHashtags(message.text)?.replaceAll('\n', '\\\n');
    if (text == null) return const SizedBox();

    return MarkdownBody(
      data: text,
      onTapLink: (link, href, title) {
        // Handle hashtag tap
      },
      styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
        p: TextStyle(color: messageTheme.text?.textColor?.resolve(StreamMessageLayout.of(context))),
        a: TextStyle(color: messageTheme.text?.linkColor?.resolve(StreamMessageLayout.of(context))),
      ),
    );
  }

  static String? _replaceHashtags(String? text) {
    if (text == null) return null;
    final exp = RegExp(r'\B#\w\w+');
    var result = text;
    for (final match in exp.allMatches(text)) {
      result = result.replaceAll(
        '${match.group(0)}',
        '[${match.group(0)}](hashtag:${match.group(0)?.replaceAll(' ', '')})',
      );
    }
    return result;
  }
}