diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index af8faad98..88a3fe5b5 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -2,6 +2,10 @@ ✅ Added +- Added `MessagePreviewFormatter` interface and `StreamMessagePreviewFormatter` implementation for + customizing message preview text formatting in channel lists and draft messages. +- Added `messagePreviewFormatter` property to `StreamChatConfigurationData` for global customization + of message preview formatting. - Added formatter properties to theme data classes for customizing date/timestamp formatting. [[#2312]](https://github.com/GetStream/stream-chat-flutter/issues/2312) [[#2406]](https://github.com/GetStream/stream-chat-flutter/issues/2406) diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_draft_message_preview_text.dart b/packages/stream_chat_flutter/lib/src/channel/stream_draft_message_preview_text.dart index 2ec1fa48a..0a935e2b6 100644 --- a/packages/stream_chat_flutter/lib/src/channel/stream_draft_message_preview_text.dart +++ b/packages/stream_chat_flutter/lib/src/channel/stream_draft_message_preview_text.dart @@ -18,18 +18,13 @@ class StreamDraftMessagePreviewText extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - final colorTheme = theme.colorTheme; + final config = StreamChatConfiguration.of(context); + final formatter = config.messagePreviewFormatter; - final previewTextSpan = TextSpan( - text: '${context.translations.draftLabel}: ', - style: textStyle?.copyWith( - fontWeight: FontWeight.bold, - color: colorTheme.accentPrimary, - ), - children: [ - TextSpan(text: draftMessage.text, style: textStyle), - ], + final previewTextSpan = formatter.formatDraftMessage( + context, + draftMessage, + textStyle: textStyle, ); return Text.rich( diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart index fdd0ce95d..adb2ffdc5 100644 --- a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart +++ b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart @@ -31,32 +31,15 @@ class StreamMessagePreviewText extends StatelessWidget { final translatedMessage = message.translate(translationLanguage); final previewMessage = translatedMessage.replaceMentions(linkify: false); - final previewText = _getPreviewText(context, previewMessage, currentUser); - - final mentionedUsers = message.mentionedUsers; - final mentionedUsersRegex = RegExp( - mentionedUsers.map((it) => '@${it.name}').join('|'), - ); - - final previewTextSpan = TextSpan( - children: [ - ...previewText.splitByRegExp(mentionedUsersRegex).map( - (text) { - // Bold the text if it is a mention user. - if (mentionedUsers.any((it) => '@${it.name}' == text)) { - return TextSpan( - text: text, - style: textStyle?.copyWith(fontWeight: FontWeight.bold), - ); - } - - return TextSpan( - text: text, - style: textStyle, - ); - }, - ) - ], + final config = StreamChatConfiguration.of(context); + final formatter = config.messagePreviewFormatter; + + final previewTextSpan = formatter.formatMessage( + context, + previewMessage, + channel: channel, + currentUser: currentUser, + textStyle: textStyle, ); return Text.rich( @@ -66,144 +49,4 @@ class StreamMessagePreviewText extends StatelessWidget { textAlign: TextAlign.start, ); } - - String _getPreviewText( - BuildContext context, - Message message, - User currentUser, - ) { - final translations = context.translations; - - if (message.isDeleted) { - return translations.messageDeletedLabel; - } - - if (message.isSystem) { - return message.text ?? translations.systemMessageLabel; - } - - if (message.poll case final poll?) { - return _pollPreviewText(context, poll, currentUser); - } - - final previewText = _previewMessageContextText(context, message); - if (previewText == null) return translations.emptyMessagePreviewText; - - if (channel case final channel?) { - if (message.user?.id == currentUser.id) { - return '${translations.youText}: $previewText'; - } - - if (channel.memberCount > 2) { - return '${message.user?.name}: $previewText'; - } - } - - return previewText; - } - - String _pollPreviewText( - BuildContext context, - Poll poll, - User currentUser, - ) { - final translations = context.translations; - - // If the poll already contains some votes, we will preview the latest voter - // and the poll name - if (poll.latestVotes.firstOrNull?.user case final latestVoter?) { - if (latestVoter.id == currentUser.id) { - final youVoted = translations.pollYouVotedText; - return '📊 $youVoted: "${poll.name}"'; - } - - final someoneVoted = translations.pollSomeoneVotedText(latestVoter.name); - return '📊 $someoneVoted: "${poll.name}"'; - } - - // Otherwise, we will show the creator of the poll and the poll name - if (poll.createdBy case final creator?) { - if (creator.id == currentUser.id) { - final youCreated = translations.pollYouCreatedText; - return '📊 $youCreated: "${poll.name}"'; - } - - final someoneCreated = translations.pollSomeoneCreatedText(creator.name); - return '📊 $someoneCreated: "${poll.name}"'; - } - - // Otherwise, we will show the poll name if it exists. - if (poll.name.trim() case final pollName when pollName.isNotEmpty) { - return '📊 $pollName'; - } - - // If nothing else, we will show the default poll emoji. - return '📊'; - } - - String? _previewMessageContextText( - BuildContext context, - Message message, - ) { - final translations = context.translations; - - final messageText = switch (message.text) { - final messageText? when messageText.isNotEmpty => messageText, - _ => null, - }; - - // If the message contains some attachments, we will show the first one - // and the text if it exists. - if (message.attachments.firstOrNull case final attachment?) { - final attachmentIcon = switch (attachment.type) { - AttachmentType.audio => '🎧', - AttachmentType.file => '📄', - AttachmentType.image => '📷', - AttachmentType.video => '📹', - AttachmentType.giphy => '/giphy', - AttachmentType.voiceRecording => '🎤', - _ => null, - }; - - final attachmentTitle = switch (attachment.type) { - AttachmentType.audio => messageText ?? translations.audioAttachmentText, - AttachmentType.file => attachment.title ?? messageText, - AttachmentType.image => messageText ?? translations.imageAttachmentText, - AttachmentType.video => messageText ?? translations.videoAttachmentText, - AttachmentType.giphy => messageText, - AttachmentType.voiceRecording => translations.voiceRecordingText, - _ => null, - }; - - if (attachmentIcon != null || attachmentTitle != null) { - return [attachmentIcon, attachmentTitle].nonNulls.join(' '); - } - } - - return messageText; - } -} - -extension on String { - List splitByRegExp(RegExp regex) { - // If the pattern is empty, return the whole string - if (regex.pattern.isEmpty) return [this]; - - final result = []; - var start = 0; - - for (final match in regex.allMatches(this)) { - if (match.start > start) { - result.add(substring(start, match.start)); - } - result.add(match.group(0)!); - start = match.end; - } - - if (start < length) { - result.add(substring(start)); - } - - return result; - } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart index 9827f5f5b..1d950ce44 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart @@ -1,7 +1,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:stream_chat_flutter/src/channel/stream_draft_message_preview_text.dart'; import 'package:stream_chat_flutter/src/message_widget/sending_indicator_builder.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/src/misc/timestamp.dart'; diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_tile.dart index 0ff46d7ac..d2311f4a3 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_tile.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/channel/stream_draft_message_preview_text.dart'; import 'package:stream_chat_flutter/src/misc/timestamp.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart index 5cac50e22..0617a8130 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart @@ -1,6 +1,5 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/channel/stream_draft_message_preview_text.dart'; import 'package:stream_chat_flutter/src/misc/timestamp.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart index e13d607fa..54fec9887 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart @@ -115,6 +115,7 @@ class StreamChatConfigurationData { List? reactionIcons, bool? enforceUniqueReactions, bool draftMessagesEnabled = false, + MessagePreviewFormatter? messagePreviewFormatter, }) { return StreamChatConfigurationData._( loadingIndicator: loadingIndicator, @@ -123,6 +124,8 @@ class StreamChatConfigurationData { reactionIcons: reactionIcons ?? _defaultReactionIcons, enforceUniqueReactions: enforceUniqueReactions ?? true, draftMessagesEnabled: draftMessagesEnabled, + messagePreviewFormatter: + messagePreviewFormatter ?? MessagePreviewFormatter(), ); } @@ -133,6 +136,7 @@ class StreamChatConfigurationData { required this.reactionIcons, required this.enforceUniqueReactions, required this.draftMessagesEnabled, + required this.messagePreviewFormatter, }); /// Copies the configuration options from one [StreamChatConfigurationData] to @@ -144,6 +148,7 @@ class StreamChatConfigurationData { List? reactionIcons, bool? enforceUniqueReactions, bool? draftMessagesEnabled, + MessagePreviewFormatter? messagePreviewFormatter, }) { return StreamChatConfigurationData( reactionIcons: reactionIcons ?? this.reactionIcons, @@ -153,6 +158,8 @@ class StreamChatConfigurationData { enforceUniqueReactions: enforceUniqueReactions ?? this.enforceUniqueReactions, draftMessagesEnabled: draftMessagesEnabled ?? this.draftMessagesEnabled, + messagePreviewFormatter: + messagePreviewFormatter ?? this.messagePreviewFormatter, ); } @@ -176,6 +183,11 @@ class StreamChatConfigurationData { /// Whether a new reaction should replace the existing one. final bool enforceUniqueReactions; + /// The formatter used for message previews throughout the application. + /// + /// Defaults to [MessagePreviewFormatter]. + final MessagePreviewFormatter messagePreviewFormatter; + static final _defaultReactionIcons = [ StreamReactionIcon( type: 'love', diff --git a/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart b/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart new file mode 100644 index 000000000..2ba4a6b72 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart @@ -0,0 +1,541 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// {@template messagePreviewFormatter} +/// Formats message previews for display. +/// +/// This interface provides two main methods for formatting message previews: +/// [formatMessage] for regular messages and [formatDraftMessage] for drafts. +/// +/// ## Default Implementation +/// +/// The factory constructor returns [StreamMessagePreviewFormatter], which +/// provides context-aware formatting based on message type, sender, and +/// channel configuration. +/// +/// ```dart +/// final formatter = MessagePreviewFormatter(); +/// final preview = formatter.formatMessage( +/// context, +/// message, +/// channel: channel, +/// currentUser: currentUser, +/// ); +/// ``` +/// +/// ## Configuration +/// +/// Set a custom formatter globally via [StreamChatConfigurationData]: +/// +/// ```dart +/// StreamChat( +/// client: client, +/// streamChatConfigurationData: StreamChatConfigurationData( +/// messagePreviewFormatter: CustomFormatter(), +/// ), +/// child: child, +/// ); +/// ``` +/// +/// ## Custom Implementation +/// +/// Extend [StreamMessagePreviewFormatter] to customize specific behaviors: +/// +/// ```dart +/// class CustomFormatter extends StreamMessagePreviewFormatter { +/// @override +/// String formatGroupMessage( +/// BuildContext context, +/// User? messageAuthor, +/// String messageText, +/// ) { +/// if (messageAuthor == null) return messageText; +/// return '${messageAuthor.name} says: $messageText'; +/// } +/// } +/// ``` +/// {@endtemplate} +abstract interface class MessagePreviewFormatter { + /// Creates a [MessagePreviewFormatter]. + /// + /// Returns the default [StreamMessagePreviewFormatter] implementation. + factory MessagePreviewFormatter() { + return const StreamMessagePreviewFormatter(); + } + + /// A formatted message preview. + /// + /// Formats [message] based on its type, [channel], and [currentUser]. + /// Mentions are bolded. The [textStyle] applies to all text. + TextSpan formatMessage( + BuildContext context, + Message message, { + ChannelModel? channel, + User? currentUser, + TextStyle? textStyle, + }); + + /// A formatted draft message preview with highlighted prefix. + /// + /// Adds a bold, accent-colored "Draft:" prefix to [draftMessage]. + /// The [textStyle] applies to the message text. + TextSpan formatDraftMessage( + BuildContext context, + DraftMessage draftMessage, { + TextStyle? textStyle, + }); +} + +/// {@template streamMessagePreviewFormatter} +/// Default implementation of [MessagePreviewFormatter]. +/// +/// This formatter applies context-aware formatting based on message type, +/// sender identity, and channel configuration. It handles various message +/// types including regular text, attachments, polls, system messages, and +/// deleted messages. +/// +/// ## Message Type Handling +/// +/// The formatter handles messages differently based on their type: +/// +/// * **Deleted messages** - Shows "Message deleted" +/// * **System messages** - Shows the message text directly +/// * **Poll messages** - Shows poll emoji with voter/creator info +/// * **Regular messages** - Shows text with optional attachment previews +/// +/// ## Sender Context +/// +/// The formatting adapts based on who sent the message: +/// +/// * **Current user** - Adds "You:" prefix +/// * **Direct messages (1-on-1)** - No prefix +/// * **Group messages** - Adds sender name prefix +/// +/// ## Customization +/// +/// All formatting methods are marked [@protected] and can be overridden: +/// +/// ```dart +/// class ShortFormatter extends StreamMessagePreviewFormatter { +/// @override +/// String formatCurrentUserMessage(BuildContext context, String text) { +/// // Remove "You:" prefix for cleaner display. +/// return text; +/// } +/// +/// @override +/// String formatPollMessage( +/// BuildContext context, +/// Poll poll, +/// User? currentUser, +/// ) { +/// // Always show just the poll name. +/// return poll.name.isEmpty ? '📊 Poll' : '📊 ${poll.name}'; +/// } +/// } +/// ``` +/// +/// ## Protected Methods +/// +/// These methods can be overridden for customization: +/// +/// **Content Extraction:** +/// * [formatRegularMessage] - Extracts message content (text + attachments) +/// * [formatMessageAttachments] - Formats attachment previews +/// +/// **Message Types:** +/// * [formatDeletedMessage] - Formats deleted messages +/// * [formatSystemMessage] - Formats system messages +/// * [formatEmptyMessage] - Formats empty messages +/// * [formatPollMessage] - Formats poll messages +/// +/// **Sender Context:** +/// * [formatCurrentUserMessage] - Formats messages from current user +/// * [formatDirectMessage] - Formats messages in 1-on-1 channels +/// * [formatGroupMessage] - Formats messages in group channels +/// +/// **Draft Messages:** +/// * [getDraftPrefix] - Returns the draft message prefix text +/// {@endtemplate} +class StreamMessagePreviewFormatter implements MessagePreviewFormatter { + /// Creates a [StreamMessagePreviewFormatter]. + const StreamMessagePreviewFormatter(); + + @override + TextSpan formatMessage( + BuildContext context, + Message message, { + ChannelModel? channel, + User? currentUser, + TextStyle? textStyle, + }) { + final previewText = _buildPreviewText( + context, + message, + channel, + currentUser, + ); + + final mentionedUsers = message.mentionedUsers; + if (mentionedUsers.isEmpty) { + return TextSpan(text: previewText, style: textStyle); + } + + final mentionedUsersRegex = RegExp( + mentionedUsers.map((it) => '@${RegExp.escape(it.name)}').join('|'), + ); + + final children = [ + ...previewText.splitByRegExp(mentionedUsersRegex).map( + (text) { + if (mentionedUsers.any((it) => '@${it.name}' == text)) { + return TextSpan( + text: text, + style: textStyle?.copyWith(fontWeight: FontWeight.bold), + ); + } + + return TextSpan(text: text, style: textStyle); + }, + ) + ]; + + return TextSpan(children: children); + } + + String _buildPreviewText( + BuildContext context, + Message message, + ChannelModel? channel, + User? currentUser, + ) { + if (message.isDeleted) { + return formatDeletedMessage(context, message); + } + + if (message.isSystem) { + return formatSystemMessage(context, message); + } + + if (message.poll case final poll?) { + return formatPollMessage(context, poll, currentUser); + } + + final messagePreviewText = formatRegularMessage(context, message); + if (messagePreviewText == null) return formatEmptyMessage(context, message); + + if (channel == null) return messagePreviewText; + + if (message.user?.id == currentUser?.id) { + return formatCurrentUserMessage(context, messagePreviewText); + } + + if (channel.memberCount > 2) { + return formatGroupMessage(context, message.user, messagePreviewText); + } + + return formatDirectMessage(context, messagePreviewText); + } + + /// The text content of a regular [message], including attachment previews. + /// + /// Extracts the message text and formats any attachments using + /// [formatMessageAttachments]. Returns `null` if the message has no text + /// or attachments. + /// + /// Override to customize how message content is extracted: + /// + /// ```dart + /// @override + /// String? formatRegularMessage(BuildContext context, Message message) { + /// // Only show text, ignore attachments + /// return message.text; + /// } + /// ``` + @protected + String? formatRegularMessage(BuildContext context, Message message) { + final messageText = switch (message.text?.trim()) { + final text? when text.isNotEmpty => text, + _ => null, + }; + + final attachments = message.attachments; + if (attachments.isEmpty) return messageText; + + return formatMessageAttachments(context, messageText, message.attachments); + } + + /// The preview text for a deleted [message]. + @protected + String formatDeletedMessage(BuildContext context, Message message) { + return context.translations.messageDeletedLabel; + } + + /// The preview text for a system [message]. + @protected + String formatSystemMessage(BuildContext context, Message message) { + if (message.text case final text? when text.isNotEmpty) return text; + return context.translations.systemMessageLabel; + } + + /// The preview text for an empty [message]. + @protected + String formatEmptyMessage(BuildContext context, Message message) { + return context.translations.emptyMessagePreviewText; + } + + /// The formatted [messageText] with "You:" prefix for the current user. + /// + /// Override this to customize how messages from the current user are + /// displayed: + /// + /// ```dart + /// @override + /// String formatCurrentUserMessage( + /// BuildContext context, + /// String messageText, + /// ) { + /// return messageText; // Remove prefix + /// } + /// ``` + @protected + String formatCurrentUserMessage(BuildContext context, String messageText) { + return '${context.translations.youText}: $messageText'; + } + + /// The [messageText] without prefix for 1-on-1 channels. + /// + /// No prefix is added since the other user's identity is clear from the + /// channel itself. Override to add context if needed: + /// + /// ```dart + /// @override + /// String formatDirectMessage(BuildContext context, String messageText) { + /// return '💬 $messageText'; + /// } + /// ``` + @protected + String formatDirectMessage(BuildContext context, String messageText) { + return messageText; + } + + /// The formatted [messageText] with [messageAuthor] name prefix for groups. + /// + /// Adds the author's name as a prefix. Returns [messageText] without + /// prefix if [messageAuthor] is `null`. + /// + /// Override to customize author name formatting: + /// + /// ```dart + /// @override + /// String formatGroupMessage( + /// BuildContext context, + /// User? messageAuthor, + /// String messageText, + /// ) { + /// if (messageAuthor == null) return messageText; + /// return '${messageAuthor.name} says: $messageText'; + /// } + /// ``` + @protected + String formatGroupMessage( + BuildContext context, + User? messageAuthor, + String messageText, + ) { + final authorName = messageAuthor?.name; + if (authorName == null || authorName.isEmpty) return messageText; + + return '$authorName: $messageText'; + } + + /// The formatted preview for the first attachment in [attachments]. + /// + /// Formats each attachment type with an emoji icon and title. The + /// [messageText] is used as fallback for certain types. Returns + /// [messageText] if no attachments are present or the type is unsupported. + /// + /// Supported types: Audio (🎧), File (📄), Image (📷), Video (📹), + /// Giphy (/giphy), and Voice Recording (🎤). + /// + /// Override to handle custom attachment types: + /// + /// ```dart + /// @override + /// String? formatMessageAttachments( + /// BuildContext context, + /// String? messageText, + /// Iterable attachments, + /// ) { + /// final attachment = attachments.firstOrNull; + /// if (attachment?.type == 'product') { + /// return '🛍️ ${attachment?.extraData['title'] ?? "Product"}'; + /// } + /// return super.formatMessageAttachments( + /// context, + /// messageText, + /// attachments, + /// ); + /// } + /// ``` + @protected + String? formatMessageAttachments( + BuildContext context, + String? messageText, + Iterable attachments, + ) { + final translations = context.translations; + final attachment = attachments.firstOrNull; + if (attachment == null) return messageText; + + // If the message contains some attachments, we will show the first one + // and the text if it exists. + final attachmentIcon = switch (attachment.type) { + AttachmentType.audio => '🎧', + AttachmentType.file => '📄', + AttachmentType.image => '📷', + AttachmentType.video => '📹', + AttachmentType.giphy => '/giphy', + AttachmentType.voiceRecording => '🎤', + _ => null, + }; + + final attachmentTitle = switch (attachment.type) { + AttachmentType.audio => messageText ?? translations.audioAttachmentText, + AttachmentType.file => attachment.title ?? messageText, + AttachmentType.image => messageText ?? translations.imageAttachmentText, + AttachmentType.video => messageText ?? translations.videoAttachmentText, + AttachmentType.giphy => messageText, + AttachmentType.voiceRecording => translations.voiceRecordingText, + _ => null, + }; + + if (attachmentIcon != null || attachmentTitle != null) { + return [attachmentIcon, attachmentTitle].nonNulls.join(' '); + } + + return messageText; + } + + /// The formatted preview for a [poll] message with voter or creator info. + /// + /// Shows the latest voter and poll name if the poll has votes, otherwise + /// shows the creator and poll name. If the poll has no votes or creator, + /// shows just the poll name. Actions by [currentUser] show as "You", + /// while actions by other users show their name. + /// + /// Override to customize poll formatting: + /// + /// ```dart + /// @override + /// String formatPollMessage( + /// BuildContext context, + /// Poll poll, + /// User? currentUser, + /// ) { + /// return poll.name.isEmpty ? '📊 Poll' : '📊 ${poll.name}'; + /// } + /// ``` + @protected + String formatPollMessage( + BuildContext context, + Poll poll, + User? currentUser, + ) { + final translations = context.translations; + + // If the poll already contains some votes, we will preview the latest voter + // and the poll name + if (poll.latestVotes.firstOrNull?.user case final latestVoter?) { + if (latestVoter.id == currentUser?.id) { + final youVoted = translations.pollYouVotedText; + return '📊 $youVoted: "${poll.name}"'; + } + + final someoneVoted = translations.pollSomeoneVotedText(latestVoter.name); + return '📊 $someoneVoted: "${poll.name}"'; + } + + // Otherwise, we will show the creator of the poll and the poll name + if (poll.createdBy case final creator?) { + if (creator.id == currentUser?.id) { + final youCreated = translations.pollYouCreatedText; + return '📊 $youCreated: "${poll.name}"'; + } + + final someoneCreated = translations.pollSomeoneCreatedText(creator.name); + return '📊 $someoneCreated: "${poll.name}"'; + } + + // Otherwise, we will show the poll name if it exists. + if (poll.name.trim() case final pollName when pollName.isNotEmpty) { + return '📊 $pollName'; + } + + // If nothing else, we will show the default poll emoji. + return '📊'; + } + + @override + TextSpan formatDraftMessage( + BuildContext context, + DraftMessage draftMessage, { + TextStyle? textStyle, + }) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + return TextSpan( + text: getDraftPrefix(context), + style: textStyle?.copyWith( + fontWeight: FontWeight.bold, + color: colorTheme.accentPrimary, + ), + children: [ + const TextSpan(text: ' '), // Space between prefix and message + TextSpan(text: draftMessage.text, style: textStyle), + ], + ); + } + + /// The draft message prefix text. + /// + /// Returns the localized "Draft" label. Override to customize the prefix: + /// + /// ```dart + /// @override + /// String getDraftPrefix(BuildContext context) { + /// return 'Unsent'; + /// } + /// ``` + @protected + String getDraftPrefix(BuildContext context) { + return '${context.translations.draftLabel}:'; + } +} + +extension on String { + List splitByRegExp(RegExp regex) { + // If the pattern is empty, return the whole string + if (regex.pattern.isEmpty) return [this]; + + final result = []; + var start = 0; + + for (final match in regex.allMatches(this)) { + if (match.start > start) { + result.add(substring(start, match.start)); + } + result.add(match.group(0)!); + start = match.end; + } + + if (start < length) { + result.add(substring(start)); + } + + return result; + } +} diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index db05edbbf..7b73be0ed 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -39,6 +39,7 @@ export 'src/channel/channel_list_header.dart'; export 'src/channel/channel_name.dart'; export 'src/channel/stream_channel_avatar.dart'; export 'src/channel/stream_channel_name.dart'; +export 'src/channel/stream_draft_message_preview_text.dart'; export 'src/channel/stream_message_preview_text.dart'; export 'src/fullscreen_media/full_screen_media.dart'; export 'src/fullscreen_media/full_screen_media_builder.dart'; @@ -130,6 +131,7 @@ export 'src/user/user_mention_tile.dart'; export 'src/utils/device_segmentation.dart'; export 'src/utils/extensions.dart'; export 'src/utils/helpers.dart'; +export 'src/utils/message_preview_formatter.dart'; export 'src/utils/typedefs.dart'; // TODO: Remove this in favor of StreamVideoAttachmentThumbnail. export 'src/video/video_thumbnail_image.dart'; diff --git a/packages/stream_chat_flutter/test/src/channel/stream_draft_message_preview_text_test.dart b/packages/stream_chat_flutter/test/src/channel/stream_draft_message_preview_text_test.dart index eb124279e..f2dd6877f 100644 --- a/packages/stream_chat_flutter/test/src/channel/stream_draft_message_preview_text_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/stream_draft_message_preview_text_test.dart @@ -1,20 +1,32 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/channel/stream_draft_message_preview_text.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import '../mocks.dart'; + void main() { // Helper to pump the draft message preview widget Future pumpDraftMessagePreview( WidgetTester tester, DraftMessage draftMessage, { TextStyle? textStyle, + StreamChatConfigurationData? configData, }) async { + final client = MockClient(); + final clientState = MockClientState(); + final currentUser = OwnUser(id: 'test-user-id', name: 'Test User'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(currentUser); + await tester.pumpWidget( MaterialApp( home: Scaffold( - body: StreamChatTheme( - data: StreamChatThemeData.light(), + body: StreamChat( + client: client, + streamChatConfigData: configData, + streamChatThemeData: StreamChatThemeData.light(), child: Center( child: StreamDraftMessagePreviewText( textStyle: textStyle, @@ -39,4 +51,36 @@ void main() { expect(find.text('Draft: This is a draft message'), findsOneWidget); }); }); + + group('Custom MessagePreviewFormatter', () { + const customFormatter = _CustomMessagePreviewFormatter(); + + testWidgets('can override getDraftPrefix', (tester) async { + final draftMessage = DraftMessage( + text: 'This is a draft message', + ); + + await pumpDraftMessagePreview( + tester, + draftMessage, + configData: StreamChatConfigurationData( + messagePreviewFormatter: customFormatter, + ), + ); + + // Custom formatter uses "✏️" instead of "Draft:" + expect(find.text('✏️ This is a draft message'), findsOneWidget); + expect(find.text('Draft: This is a draft message'), findsNothing); + }); + }); +} + +// Custom formatter for testing draft message overrides +class _CustomMessagePreviewFormatter extends StreamMessagePreviewFormatter { + const _CustomMessagePreviewFormatter(); + + @override + String getDraftPrefix(BuildContext context) { + return '✏️'; // Use "✏️" instead of "Draft:" + } } diff --git a/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart b/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart index 056d39ae5..4585e577c 100644 --- a/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart @@ -26,6 +26,7 @@ void main() { String? language, TextStyle? textStyle, ChannelModel? channel, + StreamChatConfigurationData? configData, }) async { final client = MockClient(); final clientState = MockClientState(); @@ -39,6 +40,7 @@ void main() { home: Scaffold( body: StreamChat( client: client, + streamChatConfigData: configData, streamChatThemeData: StreamChatThemeData.light(), child: Center( child: StreamMessagePreviewText( @@ -539,4 +541,190 @@ void main() { ); }); }); + + group('Custom MessagePreviewFormatter', () { + const customFormatter = _CustomMessagePreviewFormatter(); + + testWidgets('can override formatCurrentUserMessage', (tester) async { + final channel = ChannelModel( + id: 'test-channel', + type: 'messaging', + memberCount: 3, + ); + + final message = Message( + text: 'Hello everyone', + user: User(id: 'test-user-id', name: 'Test User'), // Current user + ); + + await pumpMessagePreview( + tester, + message, + channel: channel, + configData: StreamChatConfigurationData( + messagePreviewFormatter: customFormatter, + ), + ); + + // Custom formatter removes "You:" prefix + expect(find.text('Hello everyone'), findsOneWidget); + expect(find.text('You: Hello everyone'), findsNothing); + }); + + testWidgets('can override formatGroupMessage', (tester) async { + final channel = ChannelModel( + id: 'test-channel', + type: 'messaging', + memberCount: 3, + ); + + final message = Message( + text: 'Hello', + user: User(id: 'other-user-id', name: 'John Doe'), + ); + + await pumpMessagePreview( + tester, + message, + channel: channel, + configData: StreamChatConfigurationData( + messagePreviewFormatter: customFormatter, + ), + ); + + // Custom formatter uses "says:" instead of ":" + expect(find.text('John Doe says: Hello'), findsOneWidget); + }); + + testWidgets('can override formatPollMessage', (tester) async { + final message = Message( + user: User(id: 'other-user-id', name: 'Message Sender'), + poll: Poll( + name: 'Favorite Color?', + options: const [ + PollOption(id: 'option-1', text: 'Red'), + PollOption(id: 'option-2', text: 'Blue'), + ], + ), + ); + + await pumpMessagePreview( + tester, + message, + configData: StreamChatConfigurationData( + messagePreviewFormatter: customFormatter, + ), + ); + + // Custom formatter uses different format + expect(find.text('📊 Poll: Favorite Color?'), findsOneWidget); + }); + + testWidgets('can override formatMessageAttachments', (tester) async { + final message = Message( + text: '', + user: User(id: 'user-id'), + attachments: [ + Attachment( + type: 'product', + extraData: const {'title': 'iPhone'}, + ), + ], + ); + + await pumpMessagePreview( + tester, + message, + configData: StreamChatConfigurationData( + messagePreviewFormatter: customFormatter, + ), + ); + + // Custom formatter handles custom attachment type + expect(find.text('🛍️ iPhone'), findsOneWidget); + }); + + testWidgets('can override formatDirectMessage', (tester) async { + final channel = ChannelModel( + id: 'test-channel', + type: 'messaging', + memberCount: 2, + ); + + final message = Message( + text: 'Hey there', + user: User(id: 'other-user-id', name: 'Jane Doe'), + ); + + await pumpMessagePreview( + tester, + message, + channel: channel, + configData: StreamChatConfigurationData( + messagePreviewFormatter: customFormatter, + ), + ); + + // Custom formatter adds emoji prefix + expect(find.text('💬 Hey there'), findsOneWidget); + }); + }); +} + +// Custom formatter for testing overrides +class _CustomMessagePreviewFormatter extends StreamMessagePreviewFormatter { + const _CustomMessagePreviewFormatter(); + + @override + String formatCurrentUserMessage(BuildContext context, String messageText) { + // Remove "You:" prefix + return messageText; + } + + @override + String formatGroupMessage( + BuildContext context, + User? messageAuthor, + String messageText, + ) { + final authorName = messageAuthor?.name; + if (authorName == null || authorName.isEmpty) return messageText; + + // Use "says:" instead of ":" + return '$authorName says: $messageText'; + } + + @override + String formatPollMessage( + BuildContext context, + Poll poll, + User? currentUser, + ) { + // Simple format with "Poll:" prefix + return poll.name.isEmpty ? '📊 Poll' : '📊 Poll: ${poll.name}'; + } + + @override + String formatDirectMessage(BuildContext context, String messageText) { + // Add emoji prefix + return '💬 $messageText'; + } + + @override + String? formatMessageAttachments( + BuildContext context, + String? messageText, + Iterable attachments, + ) { + final attachment = attachments.firstOrNull; + + // Handle custom product attachment type + if (attachment?.type == 'product') { + final title = attachment?.extraData['title'] as String?; + return '🛍️ ${title ?? "Product"}'; + } + + // Fallback to default implementation + return super.formatMessageAttachments(context, messageText, attachments); + } }