diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index e3e6453f60..1641f6bb4c 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -18,6 +18,7 @@ - Composer UI primitives (`StreamMessageComposerInputField`, `VoiceRecordingCallback`, and the outer/inner layout containers) are now owned by `stream_chat_flutter` and exported from this package. They were previously supplied by `stream_core_flutter`. The public API of `StreamMessageComposer` / `StreamChatMessageInput` and its sub-components is unchanged. - Re-export `StreamAvatarTheme` and `StreamAvatarThemeData` from `stream_core_flutter` so consumers can theme avatars without adding a separate `stream_core_flutter` import. +- Added `messageLeading`, `messageHeader`, and `messageFooter` factory slots to `streamChatComponentBuilders` for overriding the message item's avatar, annotations, or metadata row without replacing the whole `messageItem`. 🛑️ Breaking diff --git a/packages/stream_chat_flutter/lib/src/components/stream_chat_component_builders.dart b/packages/stream_chat_flutter/lib/src/components/stream_chat_component_builders.dart index 4994d48b05..5c06c06734 100644 --- a/packages/stream_chat_flutter/lib/src/components/stream_chat_component_builders.dart +++ b/packages/stream_chat_flutter/lib/src/components/stream_chat_component_builders.dart @@ -17,6 +17,9 @@ Iterable> streamChatComponentBuilders({ StreamComponentBuilder? messageComposerInputHeader, StreamComponentBuilder? messageComposerInputTrailing, StreamComponentBuilder? messageItem, + StreamComponentBuilder? messageLeading, + StreamComponentBuilder? messageHeader, + StreamComponentBuilder? messageFooter, StreamComponentBuilder? messageComposerAttachmentList, StreamComponentBuilder? messageComposerAttachment, @@ -46,6 +49,9 @@ Iterable> streamChatComponentBuilders({ if (messageComposerInputHeader != null) StreamComponentBuilderExtension(builder: messageComposerInputHeader), if (messageComposerInputTrailing != null) StreamComponentBuilderExtension(builder: messageComposerInputTrailing), if (messageItem != null) StreamComponentBuilderExtension(builder: messageItem), + if (messageLeading != null) StreamComponentBuilderExtension(builder: messageLeading), + if (messageHeader != null) StreamComponentBuilderExtension(builder: messageHeader), + if (messageFooter != null) StreamComponentBuilderExtension(builder: messageFooter), if (messageComposerAttachmentList != null) StreamComponentBuilderExtension(builder: messageComposerAttachmentList), if (messageComposerAttachment != null) StreamComponentBuilderExtension(builder: messageComposerAttachment), if (imageAttachment != null) StreamComponentBuilderExtension(builder: imageAttachment), diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_footer.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_footer.dart index 1e6746dd2a..20d5227aa5 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_footer.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_footer.dart @@ -1,37 +1,80 @@ import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; import 'package:stream_chat_flutter/src/message_widget/components/stream_message_sending_status.dart'; -import 'package:stream_chat_flutter/src/misc/timestamp.dart'; -import 'package:stream_chat_flutter/src/stream_chat.dart'; -import 'package:stream_chat_flutter/src/utils/extensions.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_core_flutter/stream_core_flutter.dart' as core; /// Displays the row below the message bubble containing the author name, /// sending status, creation timestamp, and an edited indicator. /// -/// The footer can show up to four pieces depending on the message: -/// -/// * **Username** — for messages from other users. -/// * **Sending status** — for the current user's own messages. -/// * **Timestamp** — always shown, formatted as a short time string. -/// * **Edited label** — when the message text has been updated. +/// This widget delegates rendering to either a custom builder registered via +/// [StreamComponentFactory], or [DefaultStreamMessageFooter] when no custom +/// builder is provided. Register a custom builder through +/// `streamChatComponentBuilders(messageFooter: ...)` to fully replace the +/// default footer rendering while still receiving the same +/// [StreamMessageFooterProps]. /// /// See also: /// +/// * [StreamMessageFooterProps], which holds every configurable property. +/// * [DefaultStreamMessageFooter], the default implementation used when no +/// custom builder is registered. /// * [StreamMessageHeader], the symmetric slot above the message bubble. -/// * [StreamMessageSendingStatus], which renders the sent/delivered/read -/// indicator. -/// * [DefaultStreamMessageItem], which controls footer visibility. class StreamMessageFooter extends StatelessWidget { /// Creates a message footer for the given [message]. - const StreamMessageFooter({super.key, required this.message}); + StreamMessageFooter({super.key, required Message message}) : props = .new(message: message); + + /// Creates a message footer from pre-built [props]. + const StreamMessageFooter.fromProps({super.key, required this.props}); + + /// The properties that configure this footer. + final StreamMessageFooterProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamMessageFooter(props: props); + } +} + +/// Properties for configuring a [StreamMessageFooter]. +/// +/// See also: +/// +/// * [StreamMessageFooter], which uses these properties. +/// * [DefaultStreamMessageFooter], the default implementation. +class StreamMessageFooterProps { + /// Creates properties for a message footer. + const StreamMessageFooterProps({required this.message}); /// The message whose metadata to display. final Message message; + /// Returns a copy of this [StreamMessageFooterProps] with the given fields + /// replaced with new values. + StreamMessageFooterProps copyWith({Message? message}) { + return StreamMessageFooterProps(message: message ?? this.message); + } +} + +/// The default implementation of [StreamMessageFooter]. +/// +/// The footer can show up to four pieces depending on the message: +/// +/// * **Username** — for messages from other users. +/// * **Sending status** — for the current user's own messages. +/// * **Timestamp** — always shown, formatted as a short time string. +/// * **Edited label** — when the message text has been updated. +class DefaultStreamMessageFooter extends StatelessWidget { + /// Creates a default message footer with the given [props]. + const DefaultStreamMessageFooter({super.key, required this.props}); + + /// The properties that configure this widget. + final StreamMessageFooterProps props; + @override Widget build(BuildContext context) { + final message = props.message; final currentUser = StreamChat.of(context).currentUser; final channelKind = core.StreamMessageLayout.channelKindOf(context); diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_header.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_header.dart index 1f0b7d8712..4fa94ebdf0 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_header.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_header.dart @@ -1,34 +1,57 @@ import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:stream_chat_flutter/src/stream_chat.dart'; -import 'package:stream_chat_flutter/src/utils/extensions.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_core_flutter/stream_core_flutter.dart' as core; /// Displays contextual annotations above the message bubble for the given -/// [message]. +/// message. /// -/// Annotations are shown in the following order when applicable: -/// -/// 1. **Saved for later** — when a reminder exists without a scheduled time. -/// 2. **Pinned** — when [Message.pinned] is true, showing who pinned it. -/// 3. **Show in channel / Replied to thread** — when [Message.showInChannel] -/// is true. The label adapts based on whether the message list is a -/// channel or thread view, and includes a tappable "View" link that -/// invokes [onViewChannelTap]. -/// 4. **Reminder** — when a reminder exists with a scheduled time. -/// -/// Returns `null` when no annotations apply, allowing [StreamColumn] to -/// collapse the widget and skip spacing automatically. +/// This widget delegates rendering to either a custom builder registered via +/// [StreamComponentFactory], or [DefaultStreamMessageHeader] when no custom +/// builder is provided. Register a custom builder through +/// `streamChatComponentBuilders(messageHeader: ...)` to fully replace the +/// default header rendering while still receiving the same +/// [StreamMessageHeaderProps]. /// /// See also: /// +/// * [StreamMessageHeaderProps], which holds every configurable property. +/// * [DefaultStreamMessageHeader], the default implementation used when no +/// custom builder is registered. /// * [StreamMessageFooter], the symmetric slot below the message bubble. -/// * [DefaultStreamMessageItem], which controls header visibility. class StreamMessageHeader extends core.NullableStatelessWidget { /// Creates a message header for the given [message]. - const StreamMessageHeader({ + StreamMessageHeader({ super.key, + required Message message, + VoidCallback? onViewChannelTap, + }) : props = .new( + message: message, + onViewChannelTap: onViewChannelTap, + ); + + /// Creates a message header from pre-built [props]. + const StreamMessageHeader.fromProps({super.key, required this.props}); + + /// The properties that configure this header. + final StreamMessageHeaderProps props; + + @override + Widget? nullableBuild(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamMessageHeader(props: props); + } +} + +/// Properties for configuring a [StreamMessageHeader]. +/// +/// See also: +/// +/// * [StreamMessageHeader], which uses these properties. +/// * [DefaultStreamMessageHeader], the default implementation. +class StreamMessageHeaderProps { + /// Creates properties for a message header. + const StreamMessageHeaderProps({ required this.message, this.onViewChannelTap, }); @@ -39,8 +62,43 @@ class StreamMessageHeader extends core.NullableStatelessWidget { /// Called when the "View" link in the show-in-channel annotation is tapped. final VoidCallback? onViewChannelTap; + /// Returns a copy of this [StreamMessageHeaderProps] with the given fields + /// replaced with new values. + StreamMessageHeaderProps copyWith({ + Message? message, + VoidCallback? onViewChannelTap, + }) { + return StreamMessageHeaderProps( + message: message ?? this.message, + onViewChannelTap: onViewChannelTap ?? this.onViewChannelTap, + ); + } +} + +/// The default implementation of [StreamMessageHeader]. +/// +/// Annotations are shown in the following order when applicable: +/// +/// 1. **Saved for later** — when a reminder exists without a scheduled time. +/// 2. **Pinned** — when [Message.pinned] is true, showing who pinned it. +/// 3. **Show in channel / Replied to thread** — when [Message.showInChannel] +/// is true. The label adapts based on whether the message list is a +/// channel or thread view, and includes a tappable "View" link that +/// invokes [StreamMessageHeaderProps.onViewChannelTap]. +/// 4. **Reminder** — when a reminder exists with a scheduled time. +/// +/// Returns `null` when no annotations apply, allowing the parent layout to +/// collapse the slot and skip spacing automatically. +class DefaultStreamMessageHeader extends core.NullableStatelessWidget { + /// Creates a default message header with the given [props]. + const DefaultStreamMessageHeader({super.key, required this.props}); + + /// The properties that configure this widget. + final StreamMessageHeaderProps props; + @override Widget? nullableBuild(BuildContext context) { + final message = props.message; final translations = context.translations; final icons = context.streamIcons; final colorScheme = context.streamColorScheme; @@ -75,7 +133,7 @@ class StreamMessageHeader extends core.NullableStatelessWidget { }; showInChannelAnnotation = core.StreamMessageAnnotation( - onTap: onViewChannelTap, + onTap: props.onViewChannelTap, leading: Icon(icons.arrowUpRight), label: Text(annotationLabel), trailing: Text(translations.viewLabel), diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_leading.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_leading.dart new file mode 100644 index 0000000000..e36a0e036a --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_leading.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// Displays the leading slot of a message item — by default the author's +/// avatar shown to the side of the message bubble. +/// +/// This widget delegates rendering to either a custom builder registered via +/// [StreamComponentFactory], or [DefaultStreamMessageLeading] when no custom +/// builder is provided. Register a custom builder through +/// `streamChatComponentBuilders(messageLeading: ...)` to fully replace the +/// default leading rendering while still receiving the same +/// [StreamMessageLeadingProps]. +/// +/// See also: +/// +/// * [StreamMessageLeadingProps], which holds every configurable property. +/// * [DefaultStreamMessageLeading], the default implementation used when no +/// custom builder is registered. +/// * [StreamMessageHeader], the annotation slot above the bubble. +/// * [StreamMessageFooter], the metadata slot below the bubble. +class StreamMessageLeading extends core.NullableStatelessWidget { + /// Creates a message leading slot for the given [message]. + StreamMessageLeading({super.key, required Message message}) : props = .new(message: message); + + /// Creates a message leading slot from pre-built [props]. + const StreamMessageLeading.fromProps({super.key, required this.props}); + + /// The properties that configure this leading slot. + final StreamMessageLeadingProps props; + + @override + Widget? nullableBuild(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamMessageLeading(props: props); + } +} + +/// Properties for configuring a [StreamMessageLeading]. +/// +/// See also: +/// +/// * [StreamMessageLeading], which uses these properties. +/// * [DefaultStreamMessageLeading], the default implementation. +class StreamMessageLeadingProps { + /// Creates properties for a message leading slot. + const StreamMessageLeadingProps({required this.message}); + + /// The message whose author avatar to display. + final Message message; + + /// Returns a copy of this [StreamMessageLeadingProps] with the given fields + /// replaced with new values. + StreamMessageLeadingProps copyWith({Message? message}) { + return StreamMessageLeadingProps(message: message ?? this.message); + } +} + +/// The default implementation of [StreamMessageLeading]. +/// +/// Renders the author's avatar at the size configured by +/// [core.StreamMessageItemTheme.avatarSize], falling back to +/// [StreamAvatarSize.md]. Returns `null` when [Message.user] is null, +/// allowing the parent layout to collapse the slot and skip spacing +/// automatically. +class DefaultStreamMessageLeading extends core.NullableStatelessWidget { + /// Creates a default message leading slot with the given [props]. + const DefaultStreamMessageLeading({super.key, required this.props}); + + /// The properties that configure this widget. + final StreamMessageLeadingProps props; + + @override + Widget? nullableBuild(BuildContext context) { + final user = props.message.user; + if (user == null) return null; + + final theme = core.StreamMessageItemTheme.of(context); + final avatarSize = theme.avatarSize ?? StreamAvatarSize.md; + + return core.StreamAvatarTheme( + data: .new(size: avatarSize), + child: StreamUserAvatar(user: user, showOnlineIndicator: false), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart index 34d781f4e9..da1febabe5 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart @@ -7,8 +7,6 @@ import 'package:stream_chat_flutter/platform_widget_builder/src/platform_widget_ import 'package:stream_chat_flutter/src/context_menu/context_menu.dart'; import 'package:stream_chat_flutter/src/context_menu/context_menu_region.dart'; import 'package:stream_chat_flutter/src/message_widget/components/stream_message_content.dart'; -import 'package:stream_chat_flutter/src/message_widget/components/stream_message_footer.dart'; -import 'package:stream_chat_flutter/src/message_widget/components/stream_message_header.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_core_flutter/stream_core_flutter.dart' as core; @@ -430,17 +428,9 @@ class DefaultStreamMessageItem extends StatelessWidget { final effectiveMetadataVisibility = resolve((theme) => theme?.metadataVisibility); final effectiveRepliesVisibility = resolve((theme) => theme?.repliesVisibility); - Widget? leadingWidget; - if (props.message.user case final user?) { - final effectiveAvatarSize = theme.avatarSize ?? defaults.avatarSize; - - leadingWidget = effectiveAvatarVisibility.apply( - core.StreamAvatarTheme( - data: .new(size: effectiveAvatarSize), - child: StreamUserAvatar(user: user, showOnlineIndicator: false), - ), - ); - } + final leadingWidget = effectiveAvatarVisibility.apply( + StreamMessageLeading(message: message), + ); final headerWidget = effectiveAnnotationVisibility.apply( StreamMessageHeader( @@ -549,7 +539,7 @@ class DefaultStreamMessageItem extends StatelessWidget { alignment: StreamMessageLayout.alignmentDirectionalOf(context), child: Padding( padding: effectivePadding, - child: Row( + child: core.StreamRow( mainAxisSize: .min, spacing: effectiveSpacing, crossAxisAlignment: .end, diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 217cc41215..2e1154ffe0 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -203,6 +203,9 @@ export 'src/message_modal/message_action_confirmation_modal.dart'; export 'src/message_modal/message_actions_modal.dart'; export 'src/message_modal/message_modal.dart'; export 'src/message_modal/moderated_message_actions_modal.dart'; +export 'src/message_widget/components/stream_message_footer.dart'; +export 'src/message_widget/components/stream_message_header.dart'; +export 'src/message_widget/components/stream_message_leading.dart'; export 'src/message_widget/stream_message_item.dart'; export 'src/message_widget/stream_moderated_message.dart'; export 'src/message_widget/stream_quoted_message.dart';