From 1917e72ae940bf677830db7672ccc67d616cce68 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 18 Mar 2026 14:58:59 +0530 Subject: [PATCH 1/4] refactor(ui)!: redesign StreamMessageWidget with composable sub-components Replace the monolithic message widget implementation with a composable architecture using dedicated sub-components (StreamMessageHeader, StreamMessageFooter, StreamMessageLeading, StreamMessageContent, StreamMessageText, StreamMessageReactions, StreamMessageDeleted, StreamMessageSendingStatus). Introduce StreamMessageWidgetProps to encapsulate all configurable properties and a StreamMessageWidget.fromProps named constructor. Add nullable attachmentBuilders support on StreamMessageWidget, StreamMessageContent, and ParseAttachments, with a global fallback in StreamChatConfigurationData. Add configurable reactionType and reactionPosition to StreamChatConfigurationData (nullable, widget resolves defaults). Reaction overlap is now platform-aware (disabled on desktop/web). Remove legacy widgets: MessageWidgetContent, MessageCard, BottomRow, TextBubble, DeletedMessage, MessageText, Username, ThreadParticipants, ThreadPainter, UserAvatarTransform, SendingIndicatorBuilder, QuotedMessage, PinnedMessage, ReactionIndicator, ReactionBubbleOverlay, ReactionPickerBubbleOverlay, and MessageReactionsModal. BREAKING CHANGE: Removed several public message widget classes and the MessageReactionsModal. Consumers using these directly must migrate to the new composable sub-components or use StreamComponentFactory. --- melos.yaml | 5 +- migrations/redesign/README.md | 1 + migrations/redesign/message_widget.md | 499 +++++++ .../stream_chat_flutter/example/lib/main.dart | 19 +- .../example/lib/tutorial_part_5.dart | 6 +- .../builder/attachment_widget_builder.dart | 26 +- .../builder/mixed_attachment_builder.dart | 4 +- .../stream_chat_component_builders.dart | 4 + .../lib/src/indicators/sending_indicator.dart | 2 +- .../message_input/quoted_message_widget.dart | 4 +- .../message_list_view/message_list_view.dart | 391 ++--- .../lib/src/message_list_view/mlv_utils.dart | 36 + .../message_modal/message_actions_modal.dart | 58 +- .../lib/src/message_modal/message_modal.dart | 18 +- .../message_reactions_modal.dart | 105 -- .../lib/src/message_widget/bottom_row.dart | 295 ---- .../components/stream_message_content.dart | 213 +++ .../components/stream_message_deleted.dart | 37 + .../components/stream_message_footer.dart | 68 + .../components/stream_message_header.dart | 112 ++ .../components/stream_message_leading.dart | 33 + .../components/stream_message_reactions.dart | 88 ++ .../stream_message_sending_status.dart | 68 + .../components/stream_message_text.dart | 69 + .../src/message_widget/deleted_message.dart | 59 - .../lib/src/message_widget/message_card.dart | 270 ---- .../lib/src/message_widget/message_text.dart | 70 - .../src/message_widget/message_widget.dart | 1316 +++++++---------- .../message_widget_content.dart | 446 ------ .../message_widget_content_components.dart | 6 - .../src/message_widget/parse_attachments.dart | 18 +- .../src/message_widget/pinned_message.dart | 52 - .../src/message_widget/quoted_message.dart | 47 - .../sending_indicator_builder.dart | 80 - .../lib/src/message_widget/text_bubble.dart | 73 - .../src/message_widget/thread_painter.dart | 53 - .../message_widget/thread_participants.dart | 28 - .../message_widget/user_avatar_transform.dart | 58 - .../lib/src/message_widget/username.dart | 31 - .../indicator/reaction_indicator.dart | 123 -- .../reaction_indicator_bubble_overlay.dart | 60 - .../src/reactions/picker/reaction_picker.dart | 106 +- .../reaction_picker_bubble_overlay.dart | 60 - .../reactions/reaction_bubble_overlay.dart | 89 -- .../lib/src/stream_chat.dart | 87 +- .../lib/src/stream_chat_configuration.dart | 35 + .../lib/src/utils/date_formatter.dart | 6 +- .../lib/src/utils/typedefs.dart | 28 +- .../lib/stream_chat_flutter.dart | 11 +- packages/stream_chat_flutter/pubspec.yaml | 5 +- .../message_list_view/bottom_row_test.dart | 76 - .../stream_message_reactions_modal_dark.png | Bin 8707 -> 0 bytes .../stream_message_reactions_modal_light.png | Bin 10915 -> 0 bytes ..._message_reactions_modal_reversed_dark.png | Bin 8753 -> 0 bytes ...message_reactions_modal_reversed_light.png | Bin 10801 -> 0 bytes .../message_actions_modal_test.dart | 12 +- .../message_reactions_modal_test.dart | 327 ---- .../message_widget/deleted_message_test.dart | 214 --- .../goldens/ci/deleted_message_custom.png | Bin 945 -> 0 bytes .../goldens/ci/deleted_message_dark.png | Bin 699 -> 0 bytes .../goldens/ci/deleted_message_light.png | Bin 688 -> 0 bytes .../goldens/ci/message_text.png | Bin 872 -> 0 bytes .../src/message_widget/message_text_test.dart | 253 ---- .../src/message_widget/username_test.dart | 23 - .../ci/reaction_indicator_icon_list_dark.png | Bin 1644 -> 0 bytes .../ci/reaction_indicator_icon_list_light.png | Bin 1827 -> 0 bytes ...tion_indicator_icon_list_selected_dark.png | Bin 1783 -> 0 bytes ...ion_indicator_icon_list_selected_light.png | Bin 1899 -> 0 bytes .../ci/stream_reaction_indicator_dark.png | Bin 1660 -> 0 bytes ...tream_reaction_indicator_fallback_dark.png | Bin 1523 -> 0 bytes ...ream_reaction_indicator_fallback_light.png | Bin 1744 -> 0 bytes .../ci/stream_reaction_indicator_light.png | Bin 1938 -> 0 bytes .../ci/stream_reaction_indicator_own_dark.png | Bin 1523 -> 0 bytes .../stream_reaction_indicator_own_light.png | Bin 1744 -> 0 bytes .../indicator/reaction_indicator_test.dart | 494 ------- .../reaction_bubble_overlay_test.dart | 219 --- .../ios/Runner.xcodeproj/project.pbxproj | 4 +- sample_app/lib/app.dart | 119 +- sample_app/lib/pages/channel_page.dart | 239 +-- sample_app/lib/pages/thread_page.dart | 37 +- .../lib/widgets/custom_message_actions.dart | 196 +++ .../widgets/location/location_attachment.dart | 12 +- 82 files changed, 2477 insertions(+), 5126 deletions(-) create mode 100644 migrations/redesign/message_widget.md delete mode 100644 packages/stream_chat_flutter/lib/src/message_modal/message_reactions_modal.dart delete mode 100644 packages/stream_chat_flutter/lib/src/message_widget/bottom_row.dart create mode 100644 packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart create mode 100644 packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_deleted.dart create mode 100644 packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_footer.dart create mode 100644 packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_header.dart create mode 100644 packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_leading.dart create mode 100644 packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_reactions.dart create mode 100644 packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_sending_status.dart create mode 100644 packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart delete mode 100644 packages/stream_chat_flutter/lib/src/message_widget/deleted_message.dart delete mode 100644 packages/stream_chat_flutter/lib/src/message_widget/message_card.dart delete mode 100644 packages/stream_chat_flutter/lib/src/message_widget/message_text.dart delete mode 100644 packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart delete mode 100644 packages/stream_chat_flutter/lib/src/message_widget/message_widget_content_components.dart delete mode 100644 packages/stream_chat_flutter/lib/src/message_widget/pinned_message.dart delete mode 100644 packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart delete mode 100644 packages/stream_chat_flutter/lib/src/message_widget/sending_indicator_builder.dart delete mode 100644 packages/stream_chat_flutter/lib/src/message_widget/text_bubble.dart delete mode 100644 packages/stream_chat_flutter/lib/src/message_widget/thread_painter.dart delete mode 100644 packages/stream_chat_flutter/lib/src/message_widget/thread_participants.dart delete mode 100644 packages/stream_chat_flutter/lib/src/message_widget/user_avatar_transform.dart delete mode 100644 packages/stream_chat_flutter/lib/src/message_widget/username.dart delete mode 100644 packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator.dart delete mode 100644 packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator_bubble_overlay.dart delete mode 100644 packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker_bubble_overlay.dart delete mode 100644 packages/stream_chat_flutter/lib/src/reactions/reaction_bubble_overlay.dart delete mode 100644 packages/stream_chat_flutter/test/src/message_list_view/bottom_row_test.dart delete mode 100644 packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_dark.png delete mode 100644 packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_light.png delete mode 100644 packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_dark.png delete mode 100644 packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_light.png delete mode 100644 packages/stream_chat_flutter/test/src/message_modal/message_reactions_modal_test.dart delete mode 100644 packages/stream_chat_flutter/test/src/message_widget/deleted_message_test.dart delete mode 100644 packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_custom.png delete mode 100644 packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_dark.png delete mode 100644 packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_light.png delete mode 100644 packages/stream_chat_flutter/test/src/message_widget/goldens/ci/message_text.png delete mode 100644 packages/stream_chat_flutter/test/src/message_widget/message_text_test.dart delete mode 100644 packages/stream_chat_flutter/test/src/message_widget/username_test.dart delete mode 100644 packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/reaction_indicator_icon_list_dark.png delete mode 100644 packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/reaction_indicator_icon_list_light.png delete mode 100644 packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/reaction_indicator_icon_list_selected_dark.png delete mode 100644 packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/reaction_indicator_icon_list_selected_light.png delete mode 100644 packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_dark.png delete mode 100644 packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_fallback_dark.png delete mode 100644 packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_fallback_light.png delete mode 100644 packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_light.png delete mode 100644 packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_own_dark.png delete mode 100644 packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_own_light.png delete mode 100644 packages/stream_chat_flutter/test/src/reactions/indicator/reaction_indicator_test.dart delete mode 100644 packages/stream_chat_flutter/test/src/reactions/reaction_bubble_overlay_test.dart create mode 100644 sample_app/lib/widgets/custom_message_actions.dart diff --git a/melos.yaml b/melos.yaml index a2ef7e6846..680a845248 100644 --- a/melos.yaml +++ b/melos.yaml @@ -93,10 +93,7 @@ command: svg_icon_widget: ^0.0.1 # TODO: Replace with hosted version before merging PR stream_core_flutter: - git: - url: https://github.com/GetStream/stream-core-flutter.git - ref: 213dfb64b1d0c22a668a4a0924503703ff9a33e9 - path: packages/stream_core_flutter + path: /Users/xsahil03x/StudioProjects/stream-core-flutter/packages/stream_core_flutter synchronized: ^3.1.0+1 thumblr: ^0.0.4 url_launcher: ^6.3.0 diff --git a/migrations/redesign/README.md b/migrations/redesign/README.md index 5964be74fa..d444374bcb 100644 --- a/migrations/redesign/README.md +++ b/migrations/redesign/README.md @@ -125,6 +125,7 @@ class MyCustomButton extends StatelessWidget { | Message Actions | [message_actions.md](message_actions.md) | | Reaction Picker / Reactions | [reaction_picker.md](reaction_picker.md) | | Image CDN & Thumbnails | [image_cdn.md](image_cdn.md) | +| Message Widget & Message List | [message_widget.md](message_widget.md) | ## Need Help? diff --git a/migrations/redesign/message_widget.md b/migrations/redesign/message_widget.md new file mode 100644 index 0000000000..f85c65e47a --- /dev/null +++ b/migrations/redesign/message_widget.md @@ -0,0 +1,499 @@ +# Message Widget & Message List Migration Guide + +This guide covers migrating the message widget and message list view from the old design (`feat/design-refresh`) to the new redesigned API. + +--- + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [Architecture Changes](#architecture-changes) +- [StreamMessageWidget](#streammessagewidget) + - [Removed Parameters](#removed-parameters) + - [New Parameters](#new-parameters) + - [Changed Signatures](#changed-signatures) +- [StreamMessageListView](#streammessagelistview) + - [Builder Signature Changes](#builder-signature-changes) + - [New List-Level Callbacks](#new-list-level-callbacks) + - [Removed: MessageDetails](#removed-messagedetails) +- [Custom Actions Migration](#custom-actions-migration) +- [Theme Migration](#theme-migration) +- [Swipeable Message Example](#swipeable-message-example) +- [Deleted Classes & Files](#deleted-classes--files) +- [Typedef Changes](#typedef-changes) +- [Migration Checklist](#migration-checklist) + +--- + +## Quick Reference + +| Old | New | +|-----|-----| +| `StreamMessageWidget` (50+ params) | `StreamMessageWidget` (thin shell) + `StreamMessageWidgetProps` | +| `MessageWidgetContent` | `DefaultStreamMessage` + `StreamMessageContent` | +| `BottomRow` | `StreamMessageFooter` | +| `StreamMessageText` (message_text.dart) | `StreamMessageText` (components/stream_message_text.dart) | +| `StreamDeletedMessage` | `StreamMessageDeleted` | +| `MessageCard` | `core.StreamMessageBubble` | +| `TextBubble` | `core.StreamMessageBubble` | +| `PinnedMessage` | `streamMessageHeader()` function | +| `QuotedMessage` | Inline in `StreamMessageContent` | +| `Username` | Inline in `StreamMessageFooter` | +| `SendingIndicatorBuilder` | `StreamMessageSendingStatus` | +| `ThreadReplyPainter` | `core.StreamMessageReplies` | +| `ThreadParticipants` | Inline in `core.StreamMessageReplies` | +| `UserAvatarTransform` | `StreamMessageLeading` | +| `DisplayWidget` enum | `StreamVisibility` (from theme) | +| `MessageBuilder` typedef | `StreamMessageWidgetBuilder` typedef | +| `ParentMessageBuilder` typedef | `StreamMessageWidgetBuilder` typedef | +| `OnQuotedMessageTap = void Function(String?)` | `void Function(Message quotedMessage)` | +| `StreamMessageWidget.customActions` | `StreamMessageWidgetProps.actionsBuilder` | +| `StreamMessageWidget.onCustomActionTap` | Use `onTap` per `StreamContextMenuAction` | +| `CustomMessageAction` | Removed — use `StreamContextMenuAction` with `onTap` | +| `StreamMessageWidget.copyWith()` | `StreamMessageWidgetProps.copyWith()` | + +--- + +## Architecture Changes + +The old design used a single monolithic `StreamMessageWidget` with 50+ parameters controlling every aspect of rendering. The new design splits responsibilities: + +- **`StreamMessageWidget`** — thin shell that resolves the `StreamComponentFactory` and delegates to the factory builder or `DefaultStreamMessage`. +- **`StreamMessageWidgetProps`** — plain data class holding all configuration. Supports `copyWith()`. +- **`DefaultStreamMessage`** — the default rendering implementation. Composes the sub-components below. +- **`StreamMessageContent`** — bubble, attachments, text, reactions, thread replies. +- **`StreamMessageFooter`** — username, timestamp, sending status, edited indicator. +- **`streamMessageHeader()`** — pinned, saved-for-later, show-in-channel annotations. +- **`StreamMessageLeading`** — author avatar. +- **`StreamMessageReactions`** — clustered reaction chips around the bubble. +- **`StreamMessageText`** — markdown-rendered message text. +- **`StreamMessageDeleted`** — deleted message placeholder. +- **`StreamMessageSendingStatus`** — delivery status icon. + +### Component Factory Pattern + +The new design adds a **component factory** layer for app-wide customization. The `messageBuilder` / `parentMessageBuilder` callbacks on `StreamMessageListView` are still supported for per-list customization. + +**App-wide customization via component factory:** +```dart +StreamChat( + client: client, + componentBuilders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + messageWidget: (context, props) { + return DefaultStreamMessage( + props: props.copyWith( + actionsBuilder: (context, defaultActions) { + return [...defaultActions, myCustomAction]; + }, + ), + ); + }, + ), + ), + child: ..., +) +``` + +**Per-list customization via `messageBuilder` (still supported):** +```dart +StreamMessageListView( + messageBuilder: (context, message, defaultProps) { + return StreamMessageWidget.fromProps(props: defaultProps); + }, +) +``` + +Both can be combined — the component factory applies first, then the per-list `messageBuilder` can further customize or wrap the result. + +--- + +## StreamMessageWidget + +### Removed Parameters + +These parameters have been removed entirely. See the **Migration Path** column for how to achieve the same result. + +#### Visibility Booleans + +| Old Parameter | Migration Path | +|---|---| +| `showReactions` | Controlled via `StreamMessageItemThemeData` visibility | +| `showDeleteMessage` | Controlled via channel permissions (`canDeleteOwnMessage`, `canDeleteAnyMessage`) | +| `showEditMessage` | Controlled via channel permissions (`canUpdateOwnMessage`, `canUpdateAnyMessage`) | +| `showReplyMessage` | Controlled via channel permissions (`canSendReply`) | +| `showThreadReplyMessage` | Controlled via channel permissions (`canSendReply`) | +| `showMarkUnreadMessage` | Shown automatically when applicable | +| `showResendMessage` | Shown automatically for failed messages | +| `showCopyMessage` | Shown automatically when message has text | +| `showFlagButton` | Controlled via channel permissions (`canFlagMessage`) | +| `showPinButton` | Controlled via channel permissions (`canPinMessage`) | +| `showPinHighlight` | Controlled via `StreamMessageItemThemeData` background color | +| `showReactionPicker` | Removed | +| `showUsername` | Controlled via `StreamMessageItemThemeData.footerVisibility` | +| `showTimestamp` | Controlled via `StreamMessageItemThemeData.footerVisibility` | +| `showEditedLabel` | Controlled via `StreamMessageItemThemeData.footerVisibility` | +| `showSendingIndicator` | Controlled via `StreamMessageItemThemeData.footerVisibility` | +| `showThreadReplyIndicator` | Shown automatically when `replyCount > 0` | +| `showInChannelIndicator` | Shown automatically via `streamMessageHeader()` | +| `showUserAvatar` (`DisplayWidget`) | Controlled via `StreamMessageItemThemeData.leadingVisibility` | + +#### Builder Callbacks + +| Old Parameter | Migration Path | +|---|---| +| `userAvatarBuilder` | Use component factory to replace `DefaultStreamMessage` | +| `textBuilder` | Use component factory to replace `StreamMessageContent` | +| `quotedMessageBuilder` | Use component factory to replace `StreamMessageContent` | +| `deletedMessageBuilder` | Use component factory to replace `StreamMessageContent` | +| `editMessageInputBuilder` | Removed; use `onEditMessageTap` callback instead | +| `bottomRowBuilderWithDefaultWidget` | Use component factory; `StreamMessageFooter` is the new equivalent | +| `reactionPickerBuilder` | Configured globally via `StreamChatConfigurationData.reactionIconResolver` | +| `reactionIndicatorBuilder` | Replaced by `StreamMessageReactions` component | + +#### Shape & Style + +| Old Parameter | Migration Path | +|---|---| +| `shape` | Controlled via `StreamMessageBubble` theming in `stream_core_flutter` | +| `borderSide` | Controlled via `StreamMessageBubble` theming | +| `borderRadiusGeometry` | Controlled via `StreamMessageBubble` theming | +| `attachmentShape` | Controlled via attachment builder theming | +| `textPadding` | Controlled via `StreamMessageBubble` content padding theming | +| `attachmentPadding` | Configured internally by `ParseAttachments` | +| `messageTheme` | Resolved from context via `StreamMessageItemTheme.of(context)` | + +#### Other Removed Parameters + +| Old Parameter | Migration Path | +|---|---| +| `reverse` | Determined by `StreamMessagePlacement` context (set by list view) | +| `translateUserAvatar` | Removed; avatar positioning is theme-driven | +| `onConfirmDeleteTap` | Handled internally by `StreamMessageActionsBuilder` | +| `onShowMessage` | Removed | +| `onReactionsHover` | Removed | +| `customActions` | Use `actionsBuilder` on `StreamMessageWidgetProps` | +| `onCustomActionTap` | Use `actionsBuilder` on `StreamMessageWidgetProps` | +| `onAttachmentTap` | Handle in custom attachment builders | +| `imageAttachmentThumbnailSize` | Configured in attachment builders | +| `imageAttachmentThumbnailResizeType` | Configured in attachment builders | +| `imageAttachmentThumbnailCropType` | Configured in attachment builders | +| `attachmentActionsModalBuilder` | Configured in attachment builders | +| `attachmentBuilders` | Moved to `StreamChatConfigurationData.attachmentBuilders` (still overridable per-message via `StreamMessageWidgetProps.attachmentBuilders`) | +| `copyWith()` on `StreamMessageWidget` | Use `StreamMessageWidgetProps.copyWith()` instead | + +### New Parameters + +| New Parameter | Description | +|---|---| +| `padding` | Outer padding around the message item (overrides theme) | +| `spacing` | Horizontal spacing between avatar and content (overrides theme) | +| `backgroundColor` | Background color for the message row (overrides theme) | +| `widthFactor` | Max content width as fraction of parent (default: `0.8`) | +| `onMessageLinkTap` | `void Function(Message, String)` — receives message and URL | +| `onUserMentionTap` | `void Function(User)` — receives the mentioned user | +| `onQuotedMessageTap` | `void Function(Message)` — receives the quoted message object | +| `onReactionsTap` | `void Function(Message)` — overrides default reaction detail sheet | +| `reactionSorting` | `Comparator` for reaction display order | +| `actionsBuilder` | `MessageActionsBuilder` for customizing the actions list | +| `onMessageActions` | Override the default long-press modal entirely | +| `onBouncedErrorMessageActions` | Override the bounced-error modal entirely | +| `onEditMessageTap` | Called when edit action is selected | + +### Changed Signatures + +| Callback | Old Signature | New Signature | +|---|---|---| +| Link tap | `void Function(String url)` | `void Function(Message message, String url)` | +| Mention tap | `void Function(User user)` | `void Function(User user)` (renamed: `onMentionTap` → `onUserMentionTap`) | +| Quoted message tap | `void Function(String? quotedMessageId)` | `void Function(Message quotedMessage)` | +| Thread tap | `void Function(Message message)` | `void Function(Message message)` (unchanged signature, renamed: `onThreadTap`) | +| Reply tap | `void Function(Message message)` | `void Function(Message message)` (new: `onReplyTap`) | + +--- + +## StreamMessageListView + +### Builder Signature Changes + +Both `messageBuilder` and `parentMessageBuilder` now use the same typedef: + +**Before:** +```dart +typedef MessageBuilder = Widget Function( + BuildContext context, + MessageDetails details, + List messages, + StreamMessageWidget defaultMessageWidget, +); + +typedef ParentMessageBuilder = Widget Function( + BuildContext context, + Message? parentMessage, + StreamMessageWidget defaultMessageWidget, +); +``` + +**After:** +```dart +typedef StreamMessageWidgetBuilder = Widget Function( + BuildContext context, + Message message, + StreamMessageWidgetProps defaultProps, +); +``` + +The old builders received a pre-built `StreamMessageWidget` that you could `copyWith`. The new builders receive `StreamMessageWidgetProps` — raw configuration data. Use `StreamMessageWidget.fromProps(props:)` to build the default widget through the component factory. + +**Before:** +```dart +StreamMessageListView( + messageBuilder: (context, details, messages, defaultWidget) { + return defaultWidget.copyWith(showReactions: false); + }, +) +``` + +**After:** +```dart +StreamMessageListView( + messageBuilder: (context, message, defaultProps) { + // Build default widget (goes through component factory) + return StreamMessageWidget.fromProps(props: defaultProps); + + // Or customize props before building + return StreamMessageWidget.fromProps( + props: defaultProps.copyWith( + actionsBuilder: (context, actions) => [...actions, myAction], + ), + ); + + // Or replace entirely + return MyCustomMessageWidget(message: message); + }, +) +``` + +> **Important:** The `messageBuilder` callback now receives a `BuildContext` that has `StreamMessagePlacement` in its ancestor chain. You can call `StreamMessagePlacement.alignmentDirectionalOf(context)` to determine message alignment. + +### New List-Level Callbacks + +These callbacks were previously only configurable per-message on `StreamMessageWidget`. They are now available at the list level and forwarded to all messages: + +| New Parameter | Type | +|---|---| +| `onEditMessageTap` | `void Function(Message)?` | +| `onReplyTap` | `void Function(Message)?` | +| `onUserAvatarTap` | `void Function(User)?` | +| `onReactionsTap` | `void Function(Message)?` | +| `onQuotedMessageTap` | `void Function(Message)?` | +| `onMessageLinkTap` | `void Function(Message, String)?` | +| `onUserMentionTap` | `void Function(User)?` | + +### Changed: `showUnreadCountOnScrollToBottom` Default + +```dart +// Old +showUnreadCountOnScrollToBottom: false + +// New +showUnreadCountOnScrollToBottom: true +``` + +### Removed: MessageDetails + +The old `messageBuilder` received `MessageDetails` which contained `userId`, `message`, `messages`, and `index`. The new builder receives just `Message` and `StreamMessageWidgetProps`. The user ID is accessible via `StreamChat.of(context).currentUser?.id`. Message alignment is provided by `StreamMessagePlacement.of(context)`. + +--- + +## Custom Actions Migration + +**Before (using `customActions` + `onCustomActionTap`):** +```dart +StreamMessageWidget( + message: message, + messageTheme: theme, + customActions: [ + StreamMessageAction( + leading: Icon(Icons.info), + title: Text('Info'), + onTap: (message) => showInfo(message), + ), + ], + onCustomActionTap: (action) { + // handle CustomMessageAction + }, +) +``` + +**After (using `actionsBuilder` via component factory):** +```dart +StreamChat( + client: client, + componentBuilders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + messageWidget: (context, props) { + return DefaultStreamMessage( + props: props.copyWith( + actionsBuilder: (context, defaultActions) { + return StreamContextMenuAction.partitioned( + items: [ + ...defaultActions, + StreamContextMenuAction( + leading: Icon(context.streamIcons.informationCircle), + label: Text('Info'), + onTap: () => showInfo(props.message), + ), + ], + ); + }, + ), + ); + }, + ), + ), + child: ..., +) +``` + +**After (removing a default action):** +```dart +actionsBuilder: (context, defaultActions) { + return StreamContextMenuAction.partitioned( + items: defaultActions.where( + (a) => a.props.value is! DeleteMessage, + ).toList(), + ); +}, +``` + +> **Important:** +> - `customActions` and `onCustomActionTap` are removed +> - `CustomMessageAction` class is removed — use `StreamContextMenuAction` with `onTap` +> - `actionsBuilder` receives defaults already filtered by channel permissions +> - Return `List` — you can mix `StreamContextMenuAction` and `StreamContextMenuSeparator` + +--- + +## Theme Migration + +**Before (explicit `messageTheme` parameter):** +```dart +StreamMessageWidget( + message: message, + messageTheme: isMyMessage + ? streamTheme.ownMessageTheme + : streamTheme.otherMessageTheme, +) +``` + +**After (theme resolved automatically from context):** +```dart +StreamMessageWidget(message: message) +``` + +`StreamMessageItemTheme` is provided by `StreamChatTheme` and resolved based on `StreamMessagePlacement` (alignment, stack position, etc.). + +### StreamMessageItemThemeData + +The old per-property visibility booleans are replaced by a structured visibility system: + +```dart +StreamMessageItemThemeData( + leadingVisibility: StreamMessageStyleVisibility( + incoming: StreamVisibility.visible, + outgoing: StreamVisibility.gone, + ), + headerVisibility: StreamMessageStyleVisibility(...), + footerVisibility: StreamMessageStyleVisibility(...), + + incoming: StreamMessageItemStyle( + padding: EdgeInsets.all(4), + backgroundColor: Colors.white, + ), + outgoing: StreamMessageItemStyle( + padding: EdgeInsets.all(4), + backgroundColor: Colors.blue.shade50, + ), +) +``` + +--- + +## Swipeable Message Example + +```dart +StreamMessageListView( + messageBuilder: (context, message, defaultProps) { + final defaultWidget = StreamMessageWidget.fromProps(props: defaultProps); + + if (message.isDeleted || message.state.isFailed) return defaultWidget; + + final alignment = StreamMessagePlacement.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, + ); + }, +) +``` + +--- + +## Deleted Classes & Files + +| Old File | Old Class | Replacement | +|---|---|---| +| `message_widget_content.dart` | `MessageWidgetContent` | `DefaultStreamMessage` + `StreamMessageContent` | +| `message_widget_content_components.dart` | Various internal helpers | Merged into `components/` sub-widgets | +| `bottom_row.dart` | `BottomRow` | `StreamMessageFooter` | +| `message_text.dart` | `StreamMessageText` | `components/stream_message_text.dart` | +| `deleted_message.dart` | `StreamDeletedMessage` | `StreamMessageDeleted` | +| `message_card.dart` | `MessageCard` | `core.StreamMessageBubble` | +| `text_bubble.dart` | `TextBubble` | `core.StreamMessageBubble` | +| `pinned_message.dart` | `PinnedMessage` | `streamMessageHeader()` function | +| `quoted_message.dart` | `QuotedMessage` | Inline in `StreamMessageContent` | +| `thread_painter.dart` | `ThreadReplyPainter` | `core.StreamMessageReplies` | +| `thread_participants.dart` | `ThreadParticipants` | Inline in `core.StreamMessageReplies` | +| `user_avatar_transform.dart` | `UserAvatarTransform` | `StreamMessageLeading` | +| `username.dart` | `Username` | Inline in `StreamMessageFooter` | +| `sending_indicator_builder.dart` | `SendingIndicatorBuilder` | `StreamMessageSendingStatus` | + +--- + +## Typedef Changes + +| Old Typedef | New Typedef | +|---|---| +| `MessageBuilder = Widget Function(BuildContext, MessageDetails, List, StreamMessageWidget)` | `StreamMessageWidgetBuilder = Widget Function(BuildContext, Message, StreamMessageWidgetProps)` | +| `ParentMessageBuilder = Widget Function(BuildContext, Message?, StreamMessageWidget)` | `StreamMessageWidgetBuilder` (same as above) | +| `OnQuotedMessageTap = void Function(String?)` | Removed — use `void Function(Message)` directly | +| — | `MessageActionsBuilder = List Function(BuildContext, List>)` (new) | + +> **Note:** `MessageBuilder` and `ParentMessageBuilder` are removed from `typedefs.dart`. The new `StreamMessageWidgetBuilder` is defined in `message_list_view.dart` and exported via the barrel file. + +--- + +## Migration Checklist + +- [ ] Replace `StreamMessageWidget(message:, messageTheme:, ...)` with `StreamMessageWidget(message:)` — theme is now resolved from context +- [ ] Remove all `show*` boolean parameters — visibility is now controlled via `StreamMessageItemThemeData` and channel permissions +- [ ] Remove `customActions` and `onCustomActionTap` — use `actionsBuilder` via component factory or `StreamMessageWidgetProps.copyWith()` +- [ ] Remove all per-widget builder callbacks (`userAvatarBuilder`, `textBuilder`, `quotedMessageBuilder`, `deletedMessageBuilder`, `bottomRowBuilderWithDefaultWidget`, `reactionPickerBuilder`, `reactionIndicatorBuilder`) — use component factory instead +- [ ] Remove `shape`, `borderSide`, `borderRadiusGeometry`, `attachmentShape`, `textPadding`, `attachmentPadding` — controlled via `StreamMessageBubble` theming +- [ ] Remove `reverse` — determined by `StreamMessagePlacement` context +- [ ] Remove `translateUserAvatar` — avatar positioning is theme-driven +- [ ] Update `messageBuilder` / `parentMessageBuilder` callbacks to new `StreamMessageWidgetBuilder` signature +- [ ] Replace `MessageDetails` usage — use `StreamMessagePlacement.of(context)` for alignment, `StreamChat.of(context).currentUser` for user ID +- [ ] Update `onLinkTap` to `onMessageLinkTap` with new signature `void Function(Message, String)` +- [ ] Update `onMentionTap` to `onUserMentionTap` +- [ ] Update `onQuotedMessageTap` from `void Function(String?)` to `void Function(Message)` +- [ ] Replace `StreamDeletedMessage` with `StreamMessageDeleted` +- [ ] Replace `StreamMessageAction` with `StreamContextMenuAction` (see [message_actions.md](message_actions.md)) +- [ ] Replace `StreamSvgIcon(icon: StreamSvgIcons.*)` with `Icon(context.streamIcons.*)` +- [ ] Remove `StreamMessageWidget.copyWith()` usage — use `StreamMessageWidgetProps.copyWith()` instead diff --git a/packages/stream_chat_flutter/example/lib/main.dart b/packages/stream_chat_flutter/example/lib/main.dart index 7284f89f0f..5b0ae45d03 100644 --- a/packages/stream_chat_flutter/example/lib/main.dart +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -254,18 +254,13 @@ class _ChannelPageState extends State { Expanded( child: StreamMessageListView( threadBuilder: (_, parent) => ThreadPage(parent: parent!), - messageBuilder: - ( - context, - messageDetails, - messages, - defaultWidget, - ) { + messageBuilder: (context, message, defaultProps) { // The threshold after which the message is considered // swiped. const threshold = 0.2; - final isMyMessage = messageDetails.isMyMessage; + final currentUser = StreamChat.of(context).currentUser; + final isMyMessage = message.user?.id == currentUser?.id; // The direction in which the message can be swiped. final swipeDirection = isMyMessage @@ -274,10 +269,10 @@ class _ChannelPageState extends State { : SwipeDirection.startToEnd; return Swipeable( - key: ValueKey(messageDetails.message.id), + key: ValueKey(message.id), direction: swipeDirection, swipeThreshold: threshold, - onSwiped: (details) => reply(messageDetails.message), + onSwiped: (details) => reply(message), backgroundBuilder: (context, details) { // The alignment of the swipe action. final alignment = isMyMessage @@ -328,7 +323,9 @@ class _ChannelPageState extends State { ), ); }, - child: defaultWidget.copyWith(onReplyTap: reply), + child: DefaultStreamMessage( + props: defaultProps.copyWith(onReplyTap: reply), + ), ); }, ), diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_5.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_5.dart index 029dfdac61..d667748af8 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_5.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_5.dart @@ -128,11 +128,9 @@ class ChannelPage extends StatelessWidget { Widget _messageBuilder( BuildContext context, - MessageDetails details, - List messages, - StreamMessageWidget _, + Message message, + StreamMessageWidgetProps defaultProps, ) { - final message = details.message; final isCurrentUser = StreamChat.of(context).currentUser!.id == message.user!.id; final textAlign = isCurrentUser ? TextAlign.right : TextAlign.left; final color = isCurrentUser ? Colors.blueGrey : Colors.blue; diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart index 3bada795e5..63e08c83d4 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart @@ -68,67 +68,69 @@ abstract class StreamAttachmentWidgetBuilder { static List defaultBuilders({ required Message message, ShapeBorder? shape, - EdgeInsetsGeometry padding = const EdgeInsets.all(4), + EdgeInsetsGeometry? padding, StreamAttachmentWidgetTapCallback? onAttachmentTap, List? customAttachmentBuilders, }) { + final effectivePadding = padding ?? const EdgeInsets.symmetric(horizontal: 8); + return [ ...?customAttachmentBuilders, // Handles poll attachments. PollAttachmentBuilder( shape: shape, - padding: padding, + padding: effectivePadding, ), // Handles a mix of image, gif, video, url, file and voice recording // attachments. MixedAttachmentBuilder( - padding: padding, + padding: effectivePadding, onAttachmentTap: onAttachmentTap, ), // Handles a mix of image, gif, and video attachments. GalleryAttachmentBuilder( shape: shape, - padding: padding, - runSpacing: padding.vertical / 2, - spacing: padding.horizontal / 2, + padding: effectivePadding, + runSpacing: effectivePadding.vertical / 2, + spacing: effectivePadding.horizontal / 2, onAttachmentTap: onAttachmentTap, ), // Handles file attachments. FileAttachmentBuilder( shape: shape, - padding: padding, + padding: effectivePadding, onAttachmentTap: onAttachmentTap, ), // Handles giphy attachments. GiphyAttachmentBuilder( shape: shape, - padding: padding, + padding: effectivePadding, onAttachmentTap: onAttachmentTap, ), // Handles image attachments. ImageAttachmentBuilder( shape: shape, - padding: padding, + padding: effectivePadding, onAttachmentTap: onAttachmentTap, ), // Handles video attachments. VideoAttachmentBuilder( shape: shape, - padding: padding, + padding: effectivePadding, onAttachmentTap: onAttachmentTap, ), // Handles voice recording attachments. VoiceRecordingAttachmentPlaylistBuilder( shape: shape, - padding: padding, + padding: effectivePadding, onAttachmentTap: onAttachmentTap, ), @@ -136,7 +138,7 @@ abstract class StreamAttachmentWidgetBuilder { if (message.quotedMessage == null) UrlAttachmentBuilder( shape: shape, - padding: padding, + padding: effectivePadding, onAttachmentTap: onAttachmentTap, ), diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart index 5e272ad1eb..9b0fe31c14 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart @@ -97,10 +97,12 @@ class MixedAttachmentBuilder extends StreamAttachmentWidgetBuilder { final shouldBuildGallery = [...?images, ...?videos, ...?giphys].length > 1; + final spacing = context.streamSpacing; + return Padding( padding: padding, child: Column( - spacing: padding.vertical / 2, + spacing: spacing.xs, mainAxisSize: MainAxisSize.min, children: [ if (urls != null) 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 199ceb9fa2..de83a8ac5d 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 @@ -11,6 +11,8 @@ Iterable> streamChatComponentBuilders({ StreamComponentBuilder? messageComposerInputLeading, StreamComponentBuilder? messageComposerInputHeader, StreamComponentBuilder? messageComposerInputTrailing, + StreamComponentBuilder? reactionPicker, + StreamComponentBuilder? messageWidget, }) { final builders = [ if (channelListItem != null) StreamComponentBuilderExtension(builder: channelListItem), @@ -21,6 +23,8 @@ Iterable> streamChatComponentBuilders({ if (messageComposerInputLeading != null) StreamComponentBuilderExtension(builder: messageComposerInputLeading), if (messageComposerInputHeader != null) StreamComponentBuilderExtension(builder: messageComposerInputHeader), if (messageComposerInputTrailing != null) StreamComponentBuilderExtension(builder: messageComposerInputTrailing), + if (reactionPicker != null) StreamComponentBuilderExtension(builder: reactionPicker), + if (messageWidget != null) StreamComponentBuilderExtension(builder: messageWidget), ]; return builders; diff --git a/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart b/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart index b283bed6e1..b4fabfd62b 100644 --- a/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart @@ -12,7 +12,7 @@ class StreamSendingIndicator extends StatelessWidget { required this.message, this.isMessageRead = false, this.isMessageDelivered = false, - this.size = 12, + this.size, }); /// The message whose sending status is to be shown. diff --git a/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart b/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart index fafa0cffed..bed4365e5f 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart @@ -173,8 +173,8 @@ class _QuotedMessage extends StatelessWidget { Flexible( child: textBuilder?.call(context, msg) ?? - StreamMessageText( - message: msg, + StreamMarkdownMessage( + data: msg.replaceMentions().text ?? '', messageTheme: isOnlyEmoji && _containsText ? messageTheme.copyWith( messageTextStyle: messageTheme.messageTextStyle?.copyWith( diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index c76785084f..24c8cb717e 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -1,4 +1,3 @@ -// ignore_for_file: lines_longer_than_80_chars import 'dart:async'; import 'dart:math'; @@ -15,7 +14,7 @@ import 'package:stream_chat_flutter/src/message_list_view/unread_messages_separa import 'package:stream_chat_flutter/src/message_widget/ephemeral_message.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' hide StreamMessageWidget, StreamMessageWidgetProps; /// Spacing Types (These are properties of a message to help inform the decision /// of how much space / which widget to build after it) @@ -37,6 +36,21 @@ enum SpacingType { defaultSpacing, } +/// Signature for a function that builds a message widget from its +/// [StreamMessageWidgetProps]. +/// +/// Receives the [BuildContext], the [Message] data, and the pre-configured +/// [StreamMessageWidgetProps] with all list-level callbacks already wired in. +/// +/// Use [DefaultStreamMessage] to build the default UI, optionally modifying +/// the props via [StreamMessageWidgetProps.copyWith] first. +typedef StreamMessageWidgetBuilder = + Widget Function( + BuildContext context, + Message message, + StreamMessageWidgetProps defaultProps, + ); + /// {@template streamMessageListView} /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_listview.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_listview_paint.png) @@ -96,6 +110,12 @@ class StreamMessageListView extends StatefulWidget { this.threadBuilder, this.onThreadTap, this.onEditMessageTap, + this.onReplyTap, + this.onUserAvatarTap, + this.onReactionsTap, + this.onQuotedMessageTap, + this.onMessageLinkTap, + this.onUserMentionTap, this.dateDividerBuilder, this.floatingDateDividerBuilder, // we need to use ClampingScrollPhysics to avoid the list view to bounce @@ -140,8 +160,22 @@ class StreamMessageListView extends StatefulWidget { /// dismiss the keyboard automatically. final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; - /// {@macro messageBuilder} - final MessageBuilder? messageBuilder; + /// Optional builder for per-instance message customization. + /// + /// When set, this builder is called for each regular message with + /// pre-configured [StreamMessageWidgetProps] that have all list-level + /// callbacks already wired in. Use [StreamMessageWidgetProps.copyWith] + /// to modify properties, and [DefaultStreamMessage] to build the default + /// widget. + /// + /// For app-wide customization, use [StreamComponentFactory] instead. + final StreamMessageWidgetBuilder? messageBuilder; + + /// Optional builder for the parent message at the top of a thread. + /// + /// Works the same as [messageBuilder] but is called for the parent + /// message only. + final StreamMessageWidgetBuilder? parentMessageBuilder; /// Whether the view scrolls in the reading direction. /// @@ -170,9 +204,6 @@ class StreamMessageListView extends StatefulWidget { /// {@macro moderatedMessageBuilder} final ModeratedMessageBuilder? moderatedMessageBuilder; - /// {@macro parentMessageBuilder} - final ParentMessageBuilder? parentMessageBuilder; - /// {@macro threadBuilder} final ThreadBuilder? threadBuilder; @@ -187,6 +218,41 @@ class StreamMessageListView extends StatefulWidget { /// If provided, the inline edit flow is used instead of the edit bottom sheet. final void Function(Message)? onEditMessageTap; + /// Called when the reply action is triggered on a message. + /// + /// Forwarded to each [StreamMessageWidget] in the list. + final void Function(Message)? onReplyTap; + + /// Called when a user avatar is tapped. + /// + /// Forwarded to each [StreamMessageWidget] in the list. + final void Function(User)? onUserAvatarTap; + + /// Called when the message reactions are tapped. + /// + /// Forwarded to each [StreamMessageWidget] in the list. + final void Function(Message)? onReactionsTap; + + /// Called when a quoted message is tapped. + /// + /// When provided, this callback is forwarded to each + /// [StreamMessageWidget] in the list. + /// + /// When null (the default), tapping a quoted message scrolls to it in + /// the list, loading it if necessary. + final void Function(Message quotedMessage)? onQuotedMessageTap; + + /// Called when a link is tapped in message text. + /// + /// Receives the [Message] containing the link and the tapped URL. + /// Forwarded to each [StreamMessageWidget] in the list. + final void Function(Message message, String url)? onMessageLinkTap; + + /// Called when a user mention is tapped in message text. + /// + /// Forwarded to each [StreamMessageWidget] in the list. + final void Function(User user)? onUserMentionTap; + /// If true will show a scroll to bottom button when /// the scroll offset is not zero final bool showScrollToBottom; @@ -992,80 +1058,38 @@ class _StreamMessageListViewState extends State { Widget buildParentMessage( Message message, ) { - final isMyMessage = message.user!.id == StreamChat.of(context).currentUser!.id; - final isOnlyEmoji = message.text?.isOnlyEmoji ?? false; - - final hasFileAttachment = message.attachments.any((it) => it.type == AttachmentType.file); - - final hasUrlAttachment = message.attachments.any((it) => it.type == AttachmentType.urlPreview); - - final attachmentBorderRadius = hasUrlAttachment - ? 8.0 - : hasFileAttachment - ? 12.0 - : 14.0; - - final borderSide = isOnlyEmoji ? BorderSide.none : null; - - final defaultMessageWidget = StreamMessageWidget( + final parentMessageProps = StreamMessageWidgetProps( message: message, - reverse: isMyMessage, - showUsername: !isMyMessage, - showReactions: !message.isDeleted && !message.state.isDeletingFailed, - showReactionPicker: !message.isDeleted && !message.state.isDeletingFailed, - showReplyMessage: false, - showResendMessage: false, - showThreadReplyMessage: false, - showCopyMessage: false, - showDeleteMessage: false, - showEditMessage: false, - showMarkUnreadMessage: false, - showSendingIndicator: false, - attachmentPadding: EdgeInsets.all( - hasUrlAttachment - ? 8 - : hasFileAttachment - ? 4 - : 2, - ), - attachmentShape: RoundedRectangleBorder( - side: BorderSide( - color: _streamTheme.colorTheme.borders, - strokeAlign: BorderSide.strokeAlignOutside, - ), - borderRadius: BorderRadius.only( - topLeft: Radius.circular(attachmentBorderRadius), - bottomLeft: isMyMessage ? Radius.circular(attachmentBorderRadius) : Radius.zero, - topRight: Radius.circular(attachmentBorderRadius), - bottomRight: isMyMessage ? Radius.zero : Radius.circular(attachmentBorderRadius), - ), - ), - borderRadiusGeometry: BorderRadius.only( - topLeft: const Radius.circular(16), - bottomLeft: isMyMessage ? const Radius.circular(16) : Radius.zero, - topRight: const Radius.circular(16), - bottomRight: isMyMessage ? Radius.zero : const Radius.circular(16), - ), - textPadding: EdgeInsets.symmetric( - vertical: context.streamSpacing.xs, - horizontal: isOnlyEmoji ? 0 : context.streamSpacing.sm, - ), - borderSide: borderSide, - showUserAvatar: isMyMessage ? DisplayWidget.gone : DisplayWidget.show, - messageTheme: isMyMessage ? _streamTheme.ownMessageTheme : _streamTheme.otherMessageTheme, + onThreadTap: _onThreadTap, onMessageTap: widget.onMessageTap, onMessageLongPress: widget.onMessageLongPress, + onEditMessageTap: widget.onEditMessageTap, + onReplyTap: widget.onReplyTap, + onUserAvatarTap: widget.onUserAvatarTap, + onReactionsTap: widget.onReactionsTap, + onQuotedMessageTap: widget.onQuotedMessageTap, + onMessageLinkTap: widget.onMessageLinkTap, + onUserMentionTap: widget.onUserMentionTap, ); - if (widget.parentMessageBuilder != null) { - return widget.parentMessageBuilder!.call( - context, - widget.parentMessage, - defaultMessageWidget, - ); - } + final userId = StreamChat.of(context).currentUser!.id; + final isMyMessage = message.user?.id == userId; + + final isInThread = widget.parentMessage != null; - return defaultMessageWidget; + return StreamMessagePlacement( + data: StreamMessagePlacementData( + stackPosition: .single, + alignment: isMyMessage ? .end : .start, + listKind: isInThread ? .thread : .channel, + ), + child: Builder( + builder: (context) => switch (widget.parentMessageBuilder) { + final builder? => builder.call(context, message, parentMessageProps), + _ => StreamMessageWidget.fromProps(props: parentMessageProps), + }, + ), + ); } Widget _buildScrollToBottom() { @@ -1178,186 +1202,75 @@ class _StreamMessageListViewState extends State { return buildModeratedMessage(message); } + final messageWidgetProps = StreamMessageWidgetProps( + message: message, + onThreadTap: _onThreadTap, + onMessageTap: widget.onMessageTap, + onMessageLongPress: widget.onMessageLongPress, + onEditMessageTap: widget.onEditMessageTap, + onReplyTap: widget.onReplyTap, + onUserAvatarTap: widget.onUserAvatarTap, + onReactionsTap: widget.onReactionsTap, + onMessageLinkTap: widget.onMessageLinkTap, + onUserMentionTap: widget.onUserMentionTap, + onQuotedMessageTap: switch (widget.onQuotedMessageTap) { + final onTap? => onTap, + _ => (quotedMessage) async { + final quotedMessageId = quotedMessage.id; + if (messages.map((e) => e.id).contains(quotedMessageId)) { + final index = messages.indexWhere((m) => m.id == quotedMessageId); + _scrollController?.scrollTo( + index: index + 2, // +2 to account for loader and footer + duration: const Duration(seconds: 1), + curve: Curves.easeInOut, + alignment: 0.1, + ); + } else { + await streamChannel!.loadChannelAtMessage(quotedMessageId).then((_) async { + initialIndex = 21; // 19 + 2 | 19 is the index of the message + initialAlignment = 0.1; + }); + } + }, + }, + ); + final userId = StreamChat.of(context).currentUser!.id; final isMyMessage = message.user?.id == userId; final nextMessage = index - 1 >= 0 ? messages[index - 1] : null; - final isNextUserSame = nextMessage != null && message.user!.id == nextMessage.user!.id; - - var hasTimeDiff = false; - if (nextMessage != null) { - final createdAt = Jiffy.parseFromDateTime(message.createdAt.toLocal()); - final nextCreatedAt = Jiffy.parseFromDateTime( - nextMessage.createdAt.toLocal(), - ); - - hasTimeDiff = !createdAt.isSame(nextCreatedAt, unit: Unit.minute); - } - - final hasVoiceRecordingAttachment = message.attachments.any((it) => it.type == AttachmentType.voiceRecording); - - final hasFileAttachment = message.attachments.any((it) => it.type == AttachmentType.file); - - final hasUrlAttachment = message.attachments.any((it) => it.type == AttachmentType.urlPreview); - - final isThreadMessage = message.parentId != null && message.showInChannel == true; - - final hasReplies = message.replyCount! > 0; - - final attachmentBorderRadius = hasUrlAttachment - ? 8.0 - : hasFileAttachment - ? 12.0 - : 14.0; - - final showTimeStamp = - (!isThreadMessage || _isThreadConversation) && !hasReplies && (hasTimeDiff || !isNextUserSame); - - final showUsername = - !isMyMessage && (!isThreadMessage || _isThreadConversation) && !hasReplies && (hasTimeDiff || !isNextUserSame); - - final showMarkUnread = - streamChannel?.channel.config?.readEvents == true && - !isMyMessage && - (!isThreadMessage || _isThreadConversation); - - final showUserAvatar = isMyMessage - ? DisplayWidget.gone - : (hasTimeDiff || !isNextUserSame) - ? DisplayWidget.show - : DisplayWidget.hide; - - final showSendingIndicator = isMyMessage && (index == 0 || hasTimeDiff || !isNextUserSame); + final prevMessage = index + 1 < messages.length ? messages[index + 1] : null; - final showInChannelIndicator = !_isThreadConversation && isThreadMessage; - final showThreadReplyIndicator = !_isThreadConversation && hasReplies; - final isOnlyEmoji = message.text?.isOnlyEmoji ?? false; + final stackPosition = computeStackPosition(message: message, previous: prevMessage, next: nextMessage); - final borderSide = isOnlyEmoji ? BorderSide.none : null; - final defaultBorderRadius = context.streamRadius.xxl; + final isInThread = widget.parentMessage != null; - Widget messageWidget = StreamMessageWidget( - message: message, - reverse: isMyMessage, - showReactions: !message.isDeleted && !message.state.isDeletingFailed, - showReactionPicker: !message.isDeleted && !message.state.isDeletingFailed, - showInChannelIndicator: showInChannelIndicator, - showThreadReplyIndicator: showThreadReplyIndicator, - showUsername: showUsername, - showTimestamp: showTimeStamp, - showSendingIndicator: showSendingIndicator, - showUserAvatar: showUserAvatar, - showMarkUnreadMessage: showMarkUnread, - onQuotedMessageTap: (quotedMessageId) async { - if (messages.map((e) => e.id).contains(quotedMessageId)) { - final index = messages.indexWhere((m) => m.id == quotedMessageId); - _scrollController?.scrollTo( - index: index + 2, // +2 to account for loader and footer - duration: const Duration(seconds: 1), - curve: Curves.easeInOut, - alignment: 0.1, - ); - } else { - await streamChannel!.loadChannelAtMessage(quotedMessageId).then((_) async { - initialIndex = 21; // 19 + 2 | 19 is the index of the message - initialAlignment = 0.1; - }); - } - }, - showEditMessage: isMyMessage, - showDeleteMessage: isMyMessage, - showThreadReplyMessage: !isThreadMessage && streamChannel?.channel.canSendReply == true, - showFlagButton: !isMyMessage, - borderSide: borderSide, - onThreadTap: _onThreadTap, - onEditMessageTap: widget.onEditMessageTap, - attachmentShape: RoundedRectangleBorder( - side: BorderSide( - color: _streamTheme.colorTheme.borders, - strokeAlign: BorderSide.strokeAlignOutside, - ), - borderRadius: BorderRadius.only( - topLeft: Radius.circular(attachmentBorderRadius), - bottomLeft: isMyMessage - ? Radius.circular(attachmentBorderRadius) - : Radius.circular( - (hasTimeDiff || !isNextUserSame) && - !(hasReplies || isThreadMessage || hasFileAttachment || hasVoiceRecordingAttachment) - ? 0 - : attachmentBorderRadius, - ), - topRight: Radius.circular(attachmentBorderRadius), - bottomRight: isMyMessage - ? Radius.circular( - (hasTimeDiff || !isNextUserSame) && - !(hasReplies || isThreadMessage || hasFileAttachment || hasVoiceRecordingAttachment) - ? 0 - : attachmentBorderRadius, - ) - : Radius.circular(attachmentBorderRadius), - ), - ), - attachmentPadding: EdgeInsets.all( - hasUrlAttachment - ? 8 - : hasFileAttachment || hasVoiceRecordingAttachment - ? 4 - : 2, + Widget child = StreamMessagePlacement( + data: StreamMessagePlacementData( + stackPosition: stackPosition, + alignment: isMyMessage ? .end : .start, + listKind: isInThread ? .thread : .channel, + // channelKind: , ), - borderRadiusGeometry: BorderRadius.only( - topLeft: defaultBorderRadius, - bottomLeft: isMyMessage || !((hasTimeDiff || !isNextUserSame) && !(hasReplies || isThreadMessage)) - ? defaultBorderRadius - : Radius.zero, - topRight: defaultBorderRadius, - bottomRight: isMyMessage && (hasTimeDiff || !isNextUserSame) && !(hasReplies || isThreadMessage) - ? Radius.zero - : defaultBorderRadius, + child: Builder( + builder: (context) => switch (widget.messageBuilder) { + final builder? => builder.call(context, message, messageWidgetProps), + _ => StreamMessageWidget.fromProps(props: messageWidgetProps), + }, ), - textPadding: EdgeInsets.symmetric( - vertical: context.streamSpacing.xs, - horizontal: isOnlyEmoji ? 0 : context.streamSpacing.sm, - ), - messageTheme: isMyMessage ? _streamTheme.ownMessageTheme : _streamTheme.otherMessageTheme, - onMessageTap: widget.onMessageTap, - onMessageLongPress: widget.onMessageLongPress, ); - if (widget.messageBuilder != null) { - messageWidget = widget.messageBuilder!( - context, - MessageDetails( - userId, - message, - messages, - index, - ), - messages, - messageWidget as StreamMessageWidget, - ); - } - - var child = messageWidget; + // Highlight the initial message with an animated background color flash. if (!initialMessageHighlightComplete && widget.highlightInitialMessage && isInitialMessage(message.id, streamChannel)) { - final colorTheme = _streamTheme.colorTheme; - final highlightColor = widget.messageHighlightColor ?? colorTheme.highlight; + final colorScheme = context.streamColorScheme; + final highlightColor = widget.messageHighlightColor ?? colorScheme.backgroundHighlight; child = TweenAnimationBuilder( - tween: ColorTween( - begin: highlightColor, - // ignore: deprecated_member_use - end: colorTheme.barsBg.withOpacity(0), - ), + tween: ColorTween(begin: highlightColor, end: highlightColor.withValues(alpha: 0)), duration: const Duration(seconds: 3), onEnd: () => initialMessageHighlightComplete = true, - builder: (_, color, child) => ColoredBox( - color: color!, - child: child, - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: child, - ), + builder: (_, color, child) => ColoredBox(color: color!, child: child), + child: Padding(padding: const EdgeInsets.symmetric(vertical: 4), child: child), ); } diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart b/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart index 228c22318f..a213e6de58 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart @@ -100,3 +100,39 @@ bool isElementAtIndexVisible( bool isInitialMessage(String id, StreamChannelState? channelState) { return channelState!.initialMessageId == id; } + +/// Computes the [StreamMessageStackPosition] for [message] based on its +/// [previous] and [next] neighbors in the message list. +/// +/// A new group starts when: +/// - The neighbor is null (first/last message) +/// - The sender changes +/// - The timestamps fall in different calendar minutes +/// - The neighbor is a system, ephemeral, or error message +StreamMessageStackPosition computeStackPosition({ + required Message message, + Message? previous, + Message? next, +}) { + final isFirst = _isGroupBoundary(message, previous); + final isLast = _isGroupBoundary(message, next); + + return switch ((isFirst, isLast)) { + (true, true) => StreamMessageStackPosition.single, + (true, false) => StreamMessageStackPosition.top, + (false, false) => StreamMessageStackPosition.middle, + (false, true) => StreamMessageStackPosition.bottom, + }; +} + +bool _isGroupBoundary(Message message, Message? neighbor) { + if (neighbor == null) return true; + if (message.user?.id != neighbor.user?.id) return true; + if (neighbor.isSystem || neighbor.isEphemeral || neighbor.isError) return true; + + final createdAt = Jiffy.parseFromDateTime(message.createdAt.toLocal()); + final neighborCreatedAt = Jiffy.parseFromDateTime(neighbor.createdAt.toLocal()); + if (!createdAt.isSame(neighborCreatedAt, unit: Unit.minute)) return true; + + return false; +} diff --git a/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart index 5677a5f696..18f4a049f4 100644 --- a/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/reactions/picker/reaction_picker_bubble_overlay.dart'; - import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' show StreamMessagePlacement; /// {@template streamMessageActionsModal} /// A modal that displays a list of actions that can be performed on a message. @@ -19,9 +18,8 @@ class StreamMessageActionsModal extends StatelessWidget { required this.message, required this.messageActions, required this.messageWidget, - this.reverse = false, + this.alignment, this.showReactionPicker = false, - this.reactionPickerBuilder = StreamReactionPicker.builder, }); /// The message object that actions will be performed on. @@ -38,18 +36,15 @@ class StreamMessageActionsModal extends StatelessWidget { /// The widget representing the message being acted upon. /// - /// This is typically displayed at the top of the modal as a reference for the - /// user. + /// This is typically displayed in the content section of the modal as a + /// reference for the user. final Widget messageWidget; - /// Whether the message should be displayed in reverse direction. - /// - /// This affects how the modal and reactions are displayed and aligned. - /// Set to `true` for right-aligned messages (typically the current user's). - /// Set to `false` for left-aligned messages (typically other users'). + /// Alignment of the modal content. /// - /// Defaults to `false`. - final bool reverse; + /// When null (the default), falls back to + /// [StreamMessagePlacement.alignmentDirectionalOf]. + final AlignmentGeometry? alignment; /// Controls whether to show the reaction picker at the top of the modal. /// @@ -59,43 +54,28 @@ class StreamMessageActionsModal extends StatelessWidget { /// Defaults to `false`. final bool showReactionPicker; - /// {@macro reactionPickerBuilder} - final ReactionPickerBuilder reactionPickerBuilder; - @override Widget build(BuildContext context) { final spacing = context.streamSpacing; - - final alignment = switch (reverse) { - true => AlignmentDirectional.centerEnd, - false => AlignmentDirectional.centerStart, - }; + final effectiveAlignment = alignment ?? StreamMessagePlacement.alignmentDirectionalOf(context); void onReactionPicked(Reaction reaction) { final action = SelectReaction(message: message, reaction: reaction); - return Navigator.pop(context, action); // Pop the modal with the selected reaction action + return Navigator.pop(context, action); } return StreamMessageDialog( spacing: spacing.xs, - alignment: alignment, - headerBuilder: (context) { - final safeArea = MediaQuery.paddingOf(context); - - return Padding( - padding: EdgeInsets.only(top: safeArea.top), - child: ReactionPickerBubbleOverlay( - message: message, - reverse: reverse, - visible: showReactionPicker, - anchorOffset: Offset(0, -spacing.xs), - onReactionPicked: onReactionPicked, - reactionPickerBuilder: reactionPickerBuilder, - child: IgnorePointer(child: messageWidget), - ), - ); + alignment: effectiveAlignment, + headerBuilder: switch (showReactionPicker) { + true => (context) => StreamReactionPicker( + message: message, + onReactionPicked: onReactionPicked, + ), + false => null, }, - contentBuilder: (context) => StreamContextMenu(children: messageActions), + contentBuilder: (context) => IgnorePointer(child: messageWidget), + footerBuilder: (context) => StreamContextMenu(children: messageActions), ); } } diff --git a/packages/stream_chat_flutter/lib/src/message_modal/message_modal.dart b/packages/stream_chat_flutter/lib/src/message_modal/message_modal.dart index ec80700364..8e28dbd815 100644 --- a/packages/stream_chat_flutter/lib/src/message_modal/message_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_modal/message_modal.dart @@ -8,20 +8,22 @@ import 'package:stream_chat_flutter/src/utils/extensions.dart'; /// message-related dialog content. It handles layout, animation, and keyboard /// adjustments automatically. /// -/// The dialog can contain a header (optional) and content section (required), -/// and will adjust its position when the keyboard appears. +/// The dialog is laid out as a [Column] with three optional sections: +/// header, content, and footer. It adjusts its position when the keyboard +/// appears. /// {@endtemplate} class StreamMessageDialog extends StatelessWidget { /// Creates a Stream message dialog. /// /// The [contentBuilder] parameter is required to build the main content - /// of the dialog. The [headerBuilder] is optional and can be used to add - /// a header above the main content. + /// of the dialog. The [headerBuilder] and [footerBuilder] are optional and + /// can be used to add sections above and below the main content. const StreamMessageDialog({ super.key, this.spacing = 8.0, this.headerBuilder, required this.contentBuilder, + this.footerBuilder, this.useSafeArea = true, this.insetAnimationDuration = const Duration(milliseconds: 100), this.insetAnimationCurve = Curves.decelerate, @@ -29,7 +31,7 @@ class StreamMessageDialog extends StatelessWidget { this.alignment = Alignment.center, }); - /// Vertical spacing between header and content sections. + /// Vertical spacing between sections. final double spacing; /// Optional builder for the header section of the dialog. @@ -38,6 +40,9 @@ class StreamMessageDialog extends StatelessWidget { /// Required builder for the main content of the dialog. final WidgetBuilder contentBuilder; + /// Optional builder for the footer section of the dialog. + final WidgetBuilder? footerBuilder; + /// Whether to use a [SafeArea] to avoid system UI intrusions. /// /// Defaults to `true`. @@ -83,7 +88,8 @@ class StreamMessageDialog extends StatelessWidget { crossAxisAlignment: alignment.toColumnCrossAxisAlignment(), children: [ if (headerBuilder case final builder?) builder(context), - Flexible(child: contentBuilder(context)), + contentBuilder(context), + if (footerBuilder case final builder?) Flexible(child: builder(context)), ], ), ), diff --git a/packages/stream_chat_flutter/lib/src/message_modal/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_modal/message_reactions_modal.dart deleted file mode 100644 index 41f4ff50c0..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_modal/message_reactions_modal.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/src/reactions/picker/reaction_picker_bubble_overlay.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamMessageReactionsModal} -/// A modal that displays message reactions and allows users to add reactions. -/// -/// This modal contains: -/// 1. A reaction picker (optional) that appears at the top -/// 2. The original message widget -/// 3. A display of all current reactions with user avatars -/// -/// The modal uses [StreamMessageDialog] as its base layout and customizes -/// both the header and content sections to display reaction-specific -/// information. -/// {@endtemplate} -class StreamMessageReactionsModal extends StatelessWidget { - /// {@macro streamMessageReactionsModal} - const StreamMessageReactionsModal({ - super.key, - required this.message, - required this.messageWidget, - this.reverse = false, - this.showReactionPicker = true, - this.reactionPickerBuilder = StreamReactionPicker.builder, - this.onUserAvatarTap, - }); - - /// The message for which to display and manage reactions. - final Message message; - - /// The original message widget that will be displayed in the modal. - final Widget messageWidget; - - /// Whether the message should be displayed in reverse direction. - /// - /// This affects how the modal and reactions are displayed and aligned. - /// Set to `true` for right-aligned messages (typically the current user's). - /// Set to `false` for left-aligned messages (typically other users'). - /// - /// Defaults to `false`. - final bool reverse; - - /// Controls whether to show the reaction picker at the top of the modal. - /// - /// When `true`, users can add reactions directly from the modal. - /// When `false`, the reaction picker is hidden. - final bool showReactionPicker; - - /// {@macro reactionPickerBuilder} - final ReactionPickerBuilder reactionPickerBuilder; - - /// Callback triggered when a user avatar is tapped in the reactions list. - /// - /// Provides the [User] object associated with the tapped avatar. - final void Function(User)? onUserAvatarTap; - - @override - Widget build(BuildContext context) { - final alignment = switch (reverse) { - true => AlignmentDirectional.centerEnd, - false => AlignmentDirectional.centerStart, - }; - - void onReactionPicked(Reaction reaction) { - final action = SelectReaction(message: message, reaction: reaction); - return Navigator.pop(context, action); // Pop the modal with the selected reaction action - } - - return StreamMessageDialog( - spacing: 4, - alignment: alignment, - headerBuilder: (context) { - final safeArea = MediaQuery.paddingOf(context); - - return Padding( - padding: EdgeInsets.only(top: safeArea.top), - child: ReactionPickerBubbleOverlay( - message: message, - reverse: reverse, - visible: showReactionPicker, - anchorOffset: const Offset(0, -8), - onReactionPicked: onReactionPicked, - reactionPickerBuilder: reactionPickerBuilder, - child: IgnorePointer(child: messageWidget), - ), - ); - }, - contentBuilder: (context) { - final reactions = message.latestReactions; - final hasReactions = reactions != null && reactions.isNotEmpty; - if (!hasReactions) return const Empty(); - - return FractionallySizedBox( - widthFactor: 0.78, - child: StreamUserReactions( - message: message, - onUserAvatarTap: onUserAvatarTap, - ), - ); - }, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/bottom_row.dart b/packages/stream_chat_flutter/lib/src/message_widget/bottom_row.dart deleted file mode 100644 index 16f92ce7b1..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/bottom_row.dart +++ /dev/null @@ -1,295 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/message_widget/sending_indicator_builder.dart'; -import 'package:stream_chat_flutter/src/message_widget/thread_painter.dart'; -import 'package:stream_chat_flutter/src/message_widget/thread_participants.dart'; -import 'package:stream_chat_flutter/src/message_widget/username.dart'; -import 'package:stream_chat_flutter/src/misc/timestamp.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template bottomRow} -/// The bottom row of a [StreamMessageWidget]. -/// -/// Used in [MessageWidgetContent]. Should not be used elsewhere. -/// {@endtemplate} -class BottomRow extends StatelessWidget { - /// {@macro bottomRow} - const BottomRow({ - super.key, - required this.isDeleted, - required this.message, - required this.showThreadReplyIndicator, - required this.showInChannel, - required this.showTimeStamp, - required this.showUsername, - required this.showEditedLabel, - required this.reverse, - required this.showSendingIndicator, - required this.hasUrlAttachments, - required this.isGiphy, - required this.isOnlyEmoji, - required this.messageTheme, - required this.streamChatTheme, - required this.hasNonUrlAttachments, - required this.streamChat, - this.deletedBottomRowBuilder, - this.onThreadTap, - this.usernameBuilder, - this.sendingIndicatorBuilder, - }); - - /// {@macro messageIsDeleted} - final bool isDeleted; - - /// {@macro deletedBottomRowBuilder} - final Widget Function(BuildContext, Message)? deletedBottomRowBuilder; - - /// {@macro message} - final Message message; - - /// {@macro showThreadReplyIndicator} - final bool showThreadReplyIndicator; - - /// {@macro showInChannelIndicator} - final bool showInChannel; - - /// {@macro showTimestamp} - final bool showTimeStamp; - - /// {@macro showUsername} - final bool showUsername; - - /// {@macro showEdited} - final bool showEditedLabel; - - /// {@macro reverse} - final bool reverse; - - /// {@macro showSendingIndicator} - final bool showSendingIndicator; - - /// {@macro hasUrlAttachments} - final bool hasUrlAttachments; - - /// {@macro isGiphy} - final bool isGiphy; - - /// {@macro isOnlyEmoji} - final bool isOnlyEmoji; - - /// {@macro hasNonUrlAttachments} - final bool hasNonUrlAttachments; - - /// {@macro messageTheme} - final StreamMessageThemeData messageTheme; - - /// {@macro onThreadTap} - final void Function(Message)? onThreadTap; - - /// {@macro streamChatThemeData} - final StreamChatThemeData streamChatTheme; - - /// {@macro streamChat} - final StreamChatState streamChat; - - /// {@macro usernameBuilder} - final Widget Function(BuildContext, Message)? usernameBuilder; - - /// {@macro sendingIndicatorBuilder} - final Widget Function(BuildContext, Message)? sendingIndicatorBuilder; - - /// {@template copyWith} - /// Creates a copy of [BottomRow] with specified attributes - /// overridden. - /// {@endtemplate} - BottomRow copyWith({ - Key? key, - bool? isDeleted, - Message? message, - bool? showThreadReplyIndicator, - bool? showInChannel, - bool? showTimeStamp, - bool? showUsername, - bool? showEditedLabel, - bool? reverse, - bool? showSendingIndicator, - bool? hasUrlAttachments, - bool? isGiphy, - bool? isOnlyEmoji, - StreamMessageThemeData? messageTheme, - StreamChatThemeData? streamChatTheme, - bool? hasNonUrlAttachments, - StreamChatState? streamChat, - Widget Function(BuildContext, Message)? deletedBottomRowBuilder, - void Function(Message)? onThreadTap, - Widget Function(BuildContext, Message)? usernameBuilder, - Widget Function(BuildContext, Message)? sendingIndicatorBuilder, - }) => BottomRow( - key: key ?? this.key, - isDeleted: isDeleted ?? this.isDeleted, - message: message ?? this.message, - showThreadReplyIndicator: showThreadReplyIndicator ?? this.showThreadReplyIndicator, - showInChannel: showInChannel ?? this.showInChannel, - showTimeStamp: showTimeStamp ?? this.showTimeStamp, - showUsername: showUsername ?? this.showUsername, - showEditedLabel: showEditedLabel ?? this.showEditedLabel, - reverse: reverse ?? this.reverse, - showSendingIndicator: showSendingIndicator ?? this.showSendingIndicator, - hasUrlAttachments: hasUrlAttachments ?? this.hasUrlAttachments, - isGiphy: isGiphy ?? this.isGiphy, - isOnlyEmoji: isOnlyEmoji ?? this.isOnlyEmoji, - messageTheme: messageTheme ?? this.messageTheme, - streamChatTheme: streamChatTheme ?? this.streamChatTheme, - hasNonUrlAttachments: hasNonUrlAttachments ?? this.hasNonUrlAttachments, - streamChat: streamChat ?? this.streamChat, - deletedBottomRowBuilder: deletedBottomRowBuilder ?? this.deletedBottomRowBuilder, - onThreadTap: onThreadTap ?? this.onThreadTap, - usernameBuilder: usernameBuilder ?? this.usernameBuilder, - sendingIndicatorBuilder: sendingIndicatorBuilder ?? this.sendingIndicatorBuilder, - ); - - @override - Widget build(BuildContext context) { - if (isDeleted) { - final deletedBottomRowBuilder = this.deletedBottomRowBuilder; - if (deletedBottomRowBuilder != null) { - return deletedBottomRowBuilder(context, message); - } - } - final textTheme = context.streamTextTheme; - final textStyle = textTheme.metadataDefault.copyWith( - color: context.streamColorScheme.textTertiary, - ); - final threadParticipants = message.threadParticipants?.take(2); - final showThreadParticipants = threadParticipants?.isNotEmpty == true; - final replyCount = message.replyCount; - final isEdited = message.messageTextUpdatedAt != null; - - var msg = context.translations.threadReplyLabel; - if (showThreadReplyIndicator && replyCount! > 0) { - msg = context.translations.threadReplyCountText(replyCount); - } - - Future _onThreadTap() async { - try { - var message = this.message; - if (showInChannel) { - final channel = StreamChannel.of(context); - message = await channel.getMessage(message.parentId!); - } - return onThreadTap?.call(message); - } catch (e, stk) { - debugPrint('Error while fetching message: $e, $stk'); - } - } - - const usernameKey = Key('username'); - - final children = [ - if (showSendingIndicator) - switch (sendingIndicatorBuilder) { - final builder? => builder(context, message), - _ => SendingIndicatorBuilder( - messageTheme: messageTheme, - message: message, - hasNonUrlAttachments: hasNonUrlAttachments, - streamChat: streamChat, - streamChatTheme: streamChatTheme, - ), - }, - if (showUsername) - switch (usernameBuilder) { - final builder? => builder(context, message), - _ => Username( - key: usernameKey, - message: message, - textStyle: textStyle, - ), - }, - if (showEditedLabel && isEdited) - Text( - context.translations.editedMessageLabel, - style: textStyle, - ), - if (showTimeStamp) - StreamTimestamp( - date: message.createdAt.toLocal(), - style: textStyle, - formatter: (context, date) { - if (messageTheme.createdAtFormatter case final formatter?) { - return formatter.call(context, date); - } - - return Jiffy.parseFromDateTime(date).jm; - }, - ), - ]; - - final showThreadTail = (showThreadReplyIndicator || showInChannel) && !isOnlyEmoji; - - final threadIndicatorWidgets = [ - if (showThreadTail) - // Added builder to use the nearest context to get the right - // textScaleFactor value. - Builder( - builder: (context) { - return Padding( - padding: EdgeInsets.only( - bottom: context.textScaleFactor * ((messageTheme.repliesStyle?.fontSize ?? 1) / 2), - ), - child: CustomPaint( - size: const Size(16, 40) * context.textScaleFactor, - painter: ThreadReplyPainter( - context: context, - color: messageTheme.messageBorderColor, - reverse: reverse, - ), - ), - ); - }, - ), - if (showInChannel || showThreadReplyIndicator) ...[ - if (showThreadParticipants) - ThreadParticipants( - threadParticipants: threadParticipants!, - ), - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: _onThreadTap, - child: Text(msg, style: messageTheme.repliesStyle), - ), - ), - ], - ]; - - if (reverse) { - children.addAll(threadIndicatorWidgets.reversed); - } else { - children.insertAll(0, threadIndicatorWidgets); - } - - return Text.rich( - TextSpan( - children: [ - ...children.insertBetween(const SizedBox(width: 8)).map((child) { - final mediaQueryData = MediaQuery.of(context); - return WidgetSpan( - child: MediaQuery( - // Hardcoding the textScaleFactor to 1 to avoid the multiple - // resizing of the text. This is needed because the - // textScaleFactor is already applied to the textSpan. - // - // issue: https://github.com/GetStream/stream-chat-flutter/issues/1250 - // ignore: deprecated_member_use - data: mediaQueryData.copyWith(textScaleFactor: 1), - child: child, - ), - ); - }), - ], - ), - maxLines: 1, - textAlign: reverse ? TextAlign.right : TextAlign.left, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart new file mode 100644 index 0000000000..e1ee7f07a1 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:stream_chat_flutter/src/attachment/builder/attachment_widget_builder.dart'; +import 'package:stream_chat_flutter/src/channel/stream_message_preview_text.dart'; +import 'package:stream_chat_flutter/src/components/avatar/stream_user_avatar.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_deleted.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_reactions.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_text.dart'; +import 'package:stream_chat_flutter/src/message_widget/parse_attachments.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// Composes the main message content including the bubble, attachments, text, +/// reactions, and thread reply indicator. +/// +/// For deleted messages a [StreamMessageDeleted] placeholder is shown. +/// Otherwise the content displays attachments, message text, reactions, and +/// a thread reply indicator (when [Message.replyCount] is greater than zero). +/// +/// When the message consists of three or fewer emoji-only characters, the +/// bubble background is hidden so the emoji appear at a larger visual size. +/// +/// See also: +/// +/// * [StreamMessageReactions], which renders reactions around the bubble. +/// * [StreamMessageText], which renders the markdown message text. +/// * [DefaultStreamMessage], which hosts this widget. +class StreamMessageContent extends StatefulWidget { + /// Creates a message content widget for the given [message]. + const StreamMessageContent({ + super.key, + required this.message, + this.header, + this.footer, + this.attachmentBuilders, + this.onLinkTap, + this.onMentionTap, + this.onReactionsTap, + this.onRepliesTap, + this.onQuotedMessageTap, + this.reactionSorting, + }); + + /// The message to display. + final Message message; + + /// Optional header widget displayed above the message content column. + /// + /// Typically a [streamMessageHeader] result containing pinned, reminder, + /// or show-in-channel annotations. + final Widget? header; + + /// Optional footer widget displayed below the message content column. + /// + /// Typically a [StreamMessageFooter] containing the author name, timestamp, + /// and sending status. + final Widget? footer; + + /// Custom attachment builders for rendering message attachments. + /// + /// When non-null, these builders are passed to [ParseAttachments] and + /// take priority over the default builders. + final List? attachmentBuilders; + + /// Called when a link is tapped in the rendered message text. + /// + /// If null, tapping a link has no effect. + final MarkdownTapLinkCallback? onLinkTap; + + /// Called when a `@mention` is tapped in the rendered message text. + /// + /// If null, tapping a mention has no effect. + final core.MarkdownTapMentionCallback? onMentionTap; + + /// Called when the reactions area is tapped. + /// + /// If null, tapping reactions has no effect. + final VoidCallback? onReactionsTap; + + /// Called when the thread reply indicator is tapped. + /// + /// If null, tapping the reply indicator has no effect. + final VoidCallback? onRepliesTap; + + /// Called when the quoted message is tapped. + /// + /// If null, tapping the quoted message has no effect. + final void Function(Message quotedMessage)? onQuotedMessageTap; + + /// Controls how reaction groups are sorted when displayed. + /// + /// Passed through to [StreamMessageReactions.sorting]. + final Comparator? reactionSorting; + + @override + State createState() => _StreamMessageContentState(); +} + +class _StreamMessageContentState extends State { + // Tracks the rendered width of the attachments to constrain the bubble. + double? widthLimit; + late final attachmentsKey = GlobalKey(debugLabel: 'ParseAttachments'); + + // Measures the attachment width after layout and constrains the bubble. + void _updateWidthLimit() { + final attachmentContext = attachmentsKey.currentContext; + final renderBox = attachmentContext?.findRenderObject() as RenderBox?; + final attachmentsWidth = renderBox?.size.width; + + if (attachmentsWidth == null || attachmentsWidth == 0) return; + if (mounted) setState(() => widthLimit = attachmentsWidth); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + WidgetsBinding.instance.addPostFrameCallback((_) => _updateWidthLimit()); + } + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final crossAxisAlignment = core.StreamMessagePlacement.crossAxisAlignmentOf(context); + + if (widget.message.isDeleted) return const StreamMessageDeleted(); + + return core.StreamMessageContent( + header: widget.header, + footer: widget.footer, + child: core.StreamColumn( + mainAxisSize: .min, + crossAxisAlignment: crossAxisAlignment, + children: [ + StreamMessageReactions( + message: widget.message, + sorting: widget.reactionSorting, + onPressed: widget.onReactionsTap, + child: Builder( + builder: (context) { + final bubbleContent = ConstrainedBox( + constraints: const BoxConstraints().copyWith(maxWidth: widthLimit), + child: Column( + spacing: spacing.xxs, + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + if (widget.message.quotedMessage case final quotedMessage?) + // TODO: Refactor this with attachments + GestureDetector( + onTap: !quotedMessage.isDeleted && widget.onQuotedMessageTap != null + ? () => widget.onQuotedMessageTap!(quotedMessage) + : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: core.StreamMessageTheme( + data: core.StreamMessageThemeData( + incoming: core.StreamMessageStyle( + backgroundColor: context.streamColorScheme.backgroundSurfaceStrong, + ), + outgoing: core.StreamMessageStyle( + backgroundColor: context.streamColorScheme.brand.shade150, + ), + ), + child: core.MessageComposerReplyAttachment( + title: Text(quotedMessage.user?.name ?? ''), + subtitle: StreamMessagePreviewText(message: quotedMessage), + style: switch (core.StreamMessagePlacement.messageAlignmentOf(context)) { + core.StreamMessageAlignment.start => .incoming, + core.StreamMessageAlignment.end => .outgoing, + }, + ), + ), + ), + ), + ParseAttachments( + key: attachmentsKey, + message: widget.message, + attachmentBuilders: widget.attachmentBuilders, + attachmentPadding: .symmetric(horizontal: spacing.xs), + ), + if (widget.message.text case final text? when text.isNotEmpty) + StreamMessageText( + message: widget.message, + onLinkTap: widget.onLinkTap, + onMentionTap: widget.onMentionTap, + ), + ], + ), + ); + + final emojiCount = core.StreamMessageText.emojiOnlyCount(widget.message.text); + final hideBubble = emojiCount != null && emojiCount <= 3; + + if (hideBubble) return bubbleContent; + return core.StreamMessageBubble(child: bubbleContent); + }, + ), + ), + if (widget.message.replyCount case final replyCount? when replyCount > 0) + core.StreamMessageReplies( + maxAvatars: 3, + showConnector: true, + onTap: widget.onRepliesTap, + label: Text('$replyCount replies'), + avatars: widget.message.threadParticipants?.map( + (user) => StreamUserAvatar(user: user, showOnlineIndicator: false), + ), + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_deleted.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_deleted.dart new file mode 100644 index 0000000000..e24f8d187b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_deleted.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// Displays a "Message deleted" indicator inside a message bubble. +/// +/// Shown in place of the normal message content when [Message.isDeleted] +/// is true. +/// +/// See also: +/// +/// * [StreamMessageScaffold], which shows this widget for deleted messages. +class StreamMessageDeleted extends StatelessWidget { + /// Creates a deleted message widget. + const StreamMessageDeleted({super.key}); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + return core.StreamMessageBubble( + padding: .symmetric( + horizontal: spacing.sm, + vertical: spacing.xs, + ), + child: Row( + spacing: spacing.xxs, + mainAxisSize: .min, + children: [ + Icon(icons.circleBanSign, size: 16), + core.StreamMessageText(padding: .zero, context.translations.messageDeletedLabel), + ], + ), + ); + } +} 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 new file mode 100644 index 0000000000..9d8396754d --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_footer.dart @@ -0,0 +1,68 @@ +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_core_flutter/stream_core_flutter.dart' as core; + +/// Displays the message footer containing the author name, sending status, +/// creation timestamp, and an edited indicator. +/// +/// The footer can show up to four metadata 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. +/// +/// See also: +/// +/// * [StreamMessageSendingStatus], which renders the sent/delivered/read +/// indicator. +/// * [DefaultStreamMessage], which controls footer visibility. +class StreamMessageFooter extends StatelessWidget { + /// Creates a message footer for the given [message]. + const StreamMessageFooter({super.key, required this.message}); + + /// The message whose footer to display. + final Message message; + + @override + Widget build(BuildContext context) { + final currentUser = StreamChat.of(context).currentUser; + + Widget? usernameWidget; + if (message.user case final user? when user.id != currentUser?.id) { + usernameWidget = Text(user.name, maxLines: 1, overflow: .ellipsis); + } + + Widget? statusWidget; + if (message.user case final user? when user.id == currentUser?.id) { + statusWidget = StreamMessageSendingStatus(message: message); + } + + final Widget timestampWidget; + if (message.createdAt case final createdAt) { + timestampWidget = StreamTimestamp( + date: createdAt.toLocal(), + formatter: (context, date) { + return Jiffy.parseFromDateTime(date).jm; + }, + ); + } + + Widget? editedWidget; + if (message.messageTextUpdatedAt != null) { + editedWidget = Text(context.translations.editedMessageLabel); + } + + return core.StreamMessageMetadata( + username: usernameWidget, + status: statusWidget, + timestamp: timestampWidget, + edited: editedWidget, + ); + } +} 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 new file mode 100644 index 0000000000..8355ea9b6c --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_header.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// Builds the message header containing contextual annotations for the given +/// [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 the caller to skip +/// rendering the header entirely. +/// +/// See also: +/// +/// * [DefaultStreamMessage], which controls header visibility. +Widget? streamMessageHeader({ + required BuildContext context, + required Message message, + VoidCallback? onViewChannelTap, +}) { + final icons = context.streamIcons; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + final crossAxisAlignment = StreamMessagePlacement.crossAxisAlignmentOf(context); + + Widget? savedForLaterAnnotation; + if (message.reminder case final reminder? when reminder.remindAt == null) { + savedForLaterAnnotation = StreamMessageAnnotation( + leading: Icon(icons.bookmark, color: colorScheme.accentPrimary), + label: Text('Saved for later', style: TextStyle(color: colorScheme.accentPrimary)), + ); + } + + Widget? pinnedAnnotation; + if (message.pinned case true) { + pinnedAnnotation = StreamMessageAnnotation( + leading: Icon(icons.pin), + label: switch (message.pinnedBy) { + final pinnedBy? => Text('Pinned by ${pinnedBy.name}'), + _ => const Text('Pinned by You'), + }, + ); + } + + Widget? showInChannelAnnotation; + if (message.showInChannel case true) { + final listKind = StreamMessagePlacement.listKindOf(context); + final annotationLabel = switch (listKind) { + .channel => 'Replied to a thread · ', + .thread => 'Also sent in channel · ', + }; + + showInChannelAnnotation = StreamMessageAnnotation( + onTap: onViewChannelTap, + leading: Icon(icons.arrowUp), + label: Text.rich( + TextSpan( + text: annotationLabel, + children: [ + TextSpan( + text: 'View', + style: textTheme.metadataDefault.copyWith(color: colorScheme.textLink), + ), + ], + ), + ), + ); + } + + Widget? reminderAnnotation; + if (message.reminder?.remindAt?.toLocal() case final remindAt?) { + reminderAnnotation = StreamMessageAnnotation( + leading: Icon(icons.bellNotification), + label: Text.rich( + TextSpan( + text: 'Reminder set · ', + children: [ + TextSpan( + text: 'Today at ${Jiffy.parseFromDateTime(remindAt).jm}', + style: textTheme.metadataDefault, + ), + ], + ), + ), + ); + } + + final children = [ + ?savedForLaterAnnotation, + ?pinnedAnnotation, + ?showInChannelAnnotation, + ?reminderAnnotation, + ]; + + if (children.isEmpty) return null; + + return StreamColumn( + mainAxisSize: .min, + crossAxisAlignment: crossAxisAlignment, + children: children, + ); +} 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..ab1a956a83 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_leading.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/components/avatar/stream_user_avatar.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// Displays the message author's avatar as the leading widget in a message +/// row. +/// +/// Visibility of this widget (visible, hidden, or gone) is controlled by +/// [StreamMessageItemThemeData.leadingVisibility] in the parent +/// [DefaultStreamMessage]. +/// +/// See also: +/// +/// * [StreamUserAvatar], which renders the avatar image. +/// * [DefaultStreamMessage], which controls when this widget is shown. +class StreamMessageLeading extends StatelessWidget { + /// Creates a message leading widget for the given [author]. + const StreamMessageLeading({ + super.key, + required this.author, + }); + + /// The user whose avatar to display. + final User author; + + @override + Widget build(BuildContext context) { + return StreamUserAvatar( + user: author, + showOnlineIndicator: false, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_reactions.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_reactions.dart new file mode 100644 index 0000000000..679a069f90 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_reactions.dart @@ -0,0 +1,88 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/stream_chat_configuration.dart'; +import 'package:stream_chat_flutter/src/utils/device_segmentation.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// Displays reaction groups for a message as emoji chips overlaid on, or +/// placed beneath, the [child] widget. +/// +/// Reaction icons are resolved through the +/// [StreamChatConfigurationData.reactionIconResolver]. Groups are sorted +/// using [sorting] (defaults to [ReactionSorting.byFirstReactionAt]). +/// +/// See also: +/// +/// * [StreamMessageScaffold], which hosts this widget around the bubble. +/// * [StreamChatConfigurationData.reactionIconResolver], which maps reaction +/// type strings to emoji widgets. +class StreamMessageReactions extends StatelessWidget { + /// Creates a message reactions widget for the given [message]. + const StreamMessageReactions({ + super.key, + required this.message, + this.type, + this.position, + this.sorting, + this.onPressed, + this.child, + }); + + /// The message whose reactions to display. + final Message message; + + /// The visual type of the reactions display. + /// + /// Defaults to [core.StreamReactionsType.segmented] when null. + final core.StreamReactionsType? type; + + /// Where the reactions appear relative to the message bubble. + /// + /// Defaults to [core.StreamReactionsPosition.footer] on desktop and web, + /// and [core.StreamReactionsPosition.header] on mobile. + final core.StreamReactionsPosition? position; + + /// Controls how reaction groups are sorted when displayed. + /// + /// Defaults to [ReactionSorting.byFirstReactionAt] when null. + final Comparator? sorting; + + /// Called when the reactions area is pressed. + /// + /// If null, pressing the reactions area has no effect. + final VoidCallback? onPressed; + + /// The child widget (typically the message bubble) that reactions are + /// displayed on. + final Widget? child; + + @override + Widget build(BuildContext context) { + final config = StreamChatConfiguration.of(context); + final resolver = config.reactionIconResolver; + + final effectiveType = type ?? config.reactionType ?? core.StreamReactionsType.segmented; + final effectivePosition = position ?? config.reactionPosition ?? core.StreamReactionsPosition.header; + + final reactionGroups = message.reactionGroups?.entries; + final effectiveReactionSorting = sorting ?? ReactionSorting.byFirstReactionAt; + final sortedReactionGroups = reactionGroups?.sortedByCompare((it) => it.value, effectiveReactionSorting); + + final items = sortedReactionGroups?.map( + (group) => core.StreamReactionsItem( + count: group.value.count, + emoji: core.StreamEmoji(size: .sm, emoji: resolver.resolve(context, group.key)), + ), + ); + + return core.StreamReactions( + type: effectiveType, + position: effectivePosition, + overlap: !isDesktopDeviceOrWeb, + onPressed: onPressed, + items: [...?items], + child: child, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_sending_status.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_sending_status.dart new file mode 100644 index 0000000000..9c9e157176 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_sending_status.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/indicators/sending_indicator.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// Displays the sending status of a message, including attachment upload +/// progress and sent/delivered/read indicators. +/// +/// While attachments are still uploading, a textual progress label is shown. +/// Once the message is fully sent, an icon indicates whether it has been +/// sent, delivered, or read. +/// +/// This widget is typically used inside [StreamMessageFooter] and is only +/// shown for messages sent by the current user. +/// +/// See also: +/// +/// * [StreamSendingIndicator], which renders the sent/delivered/read icon. +/// * [StreamMessageFooter], which hosts this widget. +class StreamMessageSendingStatus extends StatelessWidget { + /// Creates a sending status widget for the given [message]. + const StreamMessageSendingStatus({ + super.key, + required this.message, + }); + + /// The message whose sending status to display. + final Message message; + + @override + Widget build(BuildContext context) { + final hasNonUrlAttachments = message.attachments.any((it) => it.type != AttachmentType.urlPreview); + + if (hasNonUrlAttachments && message.state.isOutgoing) { + final totalAttachments = message.attachments.length; + final attachmentsToUpload = message.attachments.where((it) => !it.uploadState.isSuccess); + + if (attachmentsToUpload.isNotEmpty) { + return Text( + context.translations.attachmentsUploadProgressText( + remaining: attachmentsToUpload.length, + total: totalAttachments, + ), + ); + } + } + + final channel = StreamChannel.maybeOf(context)?.channel; + + return BetterStreamBuilder>( + stream: channel?.state?.readStream, + initialData: channel?.state?.read, + builder: (context, data) { + final readList = data.readsOf(message: message); + final isMessageRead = readList.isNotEmpty; + + final deliveriesList = data.deliveriesOf(message: message); + final isMessageDelivered = deliveriesList.isNotEmpty; + + return StreamSendingIndicator( + message: message, + isMessageRead: isMessageRead, + isMessageDelivered: isMessageDelivered, + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart new file mode 100644 index 0000000000..f273f32ea2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:stream_chat_flutter/src/stream_chat.dart'; +import 'package:stream_chat_flutter/src/utils/device_segmentation.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// Displays the translated markdown message text, reacting to the current +/// user's language preference. +/// +/// The message text is translated into the current user's language, mention +/// syntax is replaced with display names, and the result is rendered as +/// markdown. +/// +/// The widget rebuilds automatically when the current user's language +/// changes, ensuring the displayed text stays in sync. +/// +/// On desktop and web the text is selectable; on mobile it is not. +/// +/// See also: +/// +/// * [StreamMessageScaffold], which hosts this widget inside a message bubble. +class StreamMessageText extends StatelessWidget { + /// Creates a message text widget for the given [message]. + const StreamMessageText({ + super.key, + required this.message, + this.onLinkTap, + this.onMentionTap, + }); + + /// The message whose text to display. + final Message message; + + /// Called when a link in the rendered markdown is tapped. + /// + /// If null, tapping a link has no effect. + final MarkdownTapLinkCallback? onLinkTap; + + /// Called when a `@mention` in the rendered markdown is tapped. + /// + /// Mentions use the `[text](mention:id)` format in the raw markdown. + /// If null, tapping a mention has no effect. + final core.MarkdownTapMentionCallback? onMentionTap; + + @override + Widget build(BuildContext context) { + final streamChat = StreamChat.of(context); + + return BetterStreamBuilder( + initialData: streamChat.currentUser?.language ?? 'en', + stream: streamChat.currentUserStream.map((it) => it?.language ?? 'en'), + builder: (context, language) { + final messageText = message.translate(language).replaceMentions().text?.replaceAll('\n', '\n\n').trim(); + + if (messageText == null || messageText.trim().isEmpty) return const Empty(); + + return core.StreamMessageText( + messageText, + selectable: isDesktopDeviceOrWeb, + onTapLink: onLinkTap, + onTapMention: onMentionTap, + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/deleted_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/deleted_message.dart deleted file mode 100644 index a184af5463..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/deleted_message.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamDeletedMessage} -/// Displays that a message was deleted at this position in the message list. -/// {@endtemplate} -class StreamDeletedMessage extends StatelessWidget { - /// {@macro streamDeletedMessage} - const StreamDeletedMessage({ - super.key, - required this.messageTheme, - this.borderRadiusGeometry, - this.shape, - this.borderSide, - this.reverse = false, - }); - - /// The theme of the message - final StreamMessageThemeData messageTheme; - - /// The border radius of the message text - final BorderRadiusGeometry? borderRadiusGeometry; - - /// The shape of the message text - final ShapeBorder? shape; - - /// The [BorderSide] of the message text - final BorderSide? borderSide; - - /// If true the widget will be mirrored - final bool reverse; - - @override - Widget build(BuildContext context) { - return Material( - color: messageTheme.messageBackgroundColor, - shape: - shape ?? - RoundedRectangleBorder( - borderRadius: borderRadiusGeometry ?? BorderRadius.zero, - side: - borderSide ?? - BorderSide( - color: messageTheme.messageBorderColor ?? Colors.transparent, - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - child: Text( - context.translations.messageDeletedLabel, - style: messageTheme.messageDeletedStyle, - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart deleted file mode 100644 index 071feadbf4..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart +++ /dev/null @@ -1,270 +0,0 @@ -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; - -/// {@template messageCard} -/// The widget containing a quoted message. -/// -/// Used in [MessageWidgetContent]. Should not be used elsewhere. -/// {@endtemplate} -class MessageCard extends StatefulWidget { - /// {@macro messageCard} - const MessageCard({ - super.key, - required this.message, - required this.isFailedState, - required this.showUserAvatar, - required this.messageTheme, - required this.hasQuotedMessage, - required this.hasUrlAttachments, - required this.hasNonUrlAttachments, - required this.isOnlyEmoji, - required this.isGiphy, - required this.attachmentBuilders, - required this.attachmentPadding, - required this.attachmentShape, - required this.onAttachmentTap, - required this.onShowMessage, - required this.onReplyTap, - required this.attachmentActionsModalBuilder, - required this.textPadding, - required this.reverse, - this.shape, - this.borderSide, - this.borderRadiusGeometry, - this.textBuilder, - this.quotedMessageBuilder, - this.onLinkTap, - this.onMentionTap, - this.onQuotedMessageTap, - }); - - /// {@macro isFailedState} - final bool isFailedState; - - /// {@macro showUserAvatar} - final DisplayWidget showUserAvatar; - - /// {@macro shape} - final ShapeBorder? shape; - - /// {@macro borderSide} - final BorderSide? borderSide; - - /// {@macro messageTheme} - final StreamMessageThemeData messageTheme; - - /// {@macro borderRadiusGeometry} - final BorderRadiusGeometry? borderRadiusGeometry; - - /// {@macro hasQuotedMessage} - final bool hasQuotedMessage; - - /// {@macro hasUrlAttachments} - final bool hasUrlAttachments; - - /// {@macro hasNonUrlAttachments} - final bool hasNonUrlAttachments; - - /// {@macro isOnlyEmoji} - final bool isOnlyEmoji; - - /// {@macro isGiphy} - final bool isGiphy; - - /// {@macro message} - final Message message; - - /// {@macro attachmentBuilders} - final List? attachmentBuilders; - - /// {@macro attachmentPadding} - final EdgeInsetsGeometry attachmentPadding; - - /// {@macro attachmentShape} - final ShapeBorder? attachmentShape; - - /// {@macro onAttachmentWidgetTap} - final OnAttachmentWidgetTap? onAttachmentTap; - - /// {@macro onShowMessage} - final ShowMessageCallback? onShowMessage; - - /// {@macro onReplyTap} - final void Function(Message)? onReplyTap; - - /// {@macro attachmentActionsBuilder} - final AttachmentActionsBuilder? attachmentActionsModalBuilder; - - /// {@macro textPadding} - final EdgeInsets textPadding; - - /// {@macro textBuilder} - final Widget Function(BuildContext, Message)? textBuilder; - - /// {@macro quotedMessageBuilder} - final Widget Function(BuildContext, Message)? quotedMessageBuilder; - - /// {@macro onLinkTap} - final void Function(String)? onLinkTap; - - /// {@macro onMentionTap} - final void Function(User)? onMentionTap; - - /// {@macro onQuotedMessageTap} - final OnQuotedMessageTap? onQuotedMessageTap; - - /// {@macro reverse} - final bool reverse; - - @override - State createState() => _MessageCardState(); -} - -class _MessageCardState extends State { - final attachmentsKey = GlobalKey(); - double? widthLimit; - - void _updateWidthLimit() { - final attachmentContext = attachmentsKey.currentContext; - final renderBox = attachmentContext?.findRenderObject() as RenderBox?; - final attachmentsWidth = renderBox?.size.width; - - if (attachmentsWidth == null || attachmentsWidth == 0) return; - - if (mounted) { - setState(() => widthLimit = attachmentsWidth); - } - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - // If there is an attachment, we need to wait for the attachment to be - // rendered to get the width of the attachment and set it as the width - // limit of the message card. - WidgetsBinding.instance.addPostFrameCallback((_) { - _updateWidthLimit(); - }); - } - - @override - Widget build(BuildContext context) { - final onQuotedMessageTap = widget.onQuotedMessageTap; - final quotedMessageBuilder = widget.quotedMessageBuilder; - final coreTheme = context.streamMessageTheme.mergeWithDefaults(context); - final messageStyle = widget.reverse ? coreTheme.outgoing! : coreTheme.incoming!; - final currentUser = StreamChat.maybeOf(context)?.currentUser; - final colorScheme = context.streamColorScheme; - - return Container( - constraints: const BoxConstraints().copyWith(maxWidth: widthLimit), - margin: EdgeInsetsDirectional.only( - end: widget.reverse && widget.isFailedState ? 12.0 : 0.0, - start: !widget.reverse && widget.isFailedState ? 12.0 : 0.0, - ), - clipBehavior: Clip.hardEdge, - decoration: _buildDecoration(messageStyle, widget.messageTheme), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.hasQuotedMessage) - InkWell( - onTap: !widget.message.quotedMessage!.isDeleted && onQuotedMessageTap != null - ? () => onQuotedMessageTap(widget.message.quotedMessageId) - : null, - child: core.StreamMessageTheme( - data: core.StreamMessageThemeData( - incoming: core.StreamMessageStyle( - backgroundColor: colorScheme.backgroundSurfaceStrong, - ), - outgoing: core.StreamMessageStyle( - backgroundColor: colorScheme.brand.shade150, - ), - ), - child: - quotedMessageBuilder?.call( - context, - widget.message.quotedMessage!, - ) ?? - core.MessageComposerReplyAttachment( - title: Text(widget.message.quotedMessage!.user?.name ?? ''), - subtitle: StreamMessagePreviewText(message: widget.message.quotedMessage!), - style: currentUser?.id == widget.message.quotedMessage!.user?.id ? .outgoing : .incoming, - ), - ), - ), - ParseAttachments( - key: attachmentsKey, - message: widget.message, - attachmentBuilders: widget.attachmentBuilders, - attachmentPadding: widget.attachmentPadding, - attachmentShape: widget.attachmentShape, - onAttachmentTap: widget.onAttachmentTap, - onShowMessage: widget.onShowMessage, - onLinkTap: widget.onLinkTap, - onReplyTap: widget.onReplyTap, - attachmentActionsModalBuilder: widget.attachmentActionsModalBuilder, - ), - TextBubble( - messageStyle: messageStyle, - message: widget.message, - textPadding: widget.textPadding, - textBuilder: widget.textBuilder, - isOnlyEmoji: widget.isOnlyEmoji, - hasQuotedMessage: widget.hasQuotedMessage, - hasUrlAttachments: widget.hasUrlAttachments, - onLinkTap: widget.onLinkTap, - onMentionTap: widget.onMentionTap, - ), - ], - ), - ); - } - - ShapeDecoration _buildDecoration(core.StreamMessageStyle messageStyle, StreamMessageThemeData theme) { - final gradient = _getBackgroundGradient(theme); - final color = gradient == null ? _getBackgroundColor(messageStyle) : null; - - final borderColor = theme.messageBorderColor ?? Colors.transparent; - final borderRadius = widget.borderRadiusGeometry ?? BorderRadius.zero; - - return ShapeDecoration( - color: color, - gradient: gradient, - shape: switch (widget.shape) { - final shape? => shape, - _ => RoundedRectangleBorder( - borderRadius: borderRadius, - side: switch (widget.borderSide) { - final side? => side, - _ => BorderSide(color: borderColor), - }, - ), - }, - ); - } - - Color? _getBackgroundColor(core.StreamMessageStyle theme) { - if (widget.hasQuotedMessage) { - return theme.backgroundColor; - } - - final containsOnlyUrlAttachment = widget.hasUrlAttachments && !widget.hasNonUrlAttachments; - - if (containsOnlyUrlAttachment) { - return theme.backgroundAttachmentColor; - } - - if (widget.isOnlyEmoji) return null; - - return theme.backgroundColor; - } - - Gradient? _getBackgroundGradient(StreamMessageThemeData theme) { - if (widget.isOnlyEmoji) return null; - - return theme.messageBackgroundGradient; - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_text.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_text.dart deleted file mode 100644 index 60975daf1d..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_text.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:collection/collection.dart' show IterableExtension; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamMessageText} -/// The text content of a message. -/// {@endtemplate} -class StreamMessageText extends StatelessWidget { - /// {@macro streamMessageText} - const StreamMessageText({ - super.key, - required this.message, - this.messageTheme, - this.onMentionTap, - this.onLinkTap, - }); - - /// Message whose text is to be displayed - final Message message; - - /// The action to perform when a mention is tapped - final void Function(User)? onMentionTap; - - /// The action to perform when a link is tapped - final void Function(String)? onLinkTap; - - /// [StreamMessageThemeData] whose text theme is to be applied - final StreamMessageThemeData? messageTheme; - - @override - Widget build(BuildContext context) { - final streamChat = StreamChat.of(context); - assert(streamChat.currentUser != null, ''); - return BetterStreamBuilder( - stream: streamChat.currentUserStream.map((it) => it!.language ?? 'en'), - initialData: streamChat.currentUser!.language ?? 'en', - builder: (context, language) { - final messageText = message.translate(language).replaceMentions().text?.replaceAll('\n', '\n\n').trim(); - - return StreamMarkdownMessage( - data: messageText ?? '', - messageTheme: messageTheme, - selectable: isDesktopDeviceOrWeb, - onTapLink: - ( - String text, - String? href, - String title, - ) { - if (text.startsWith('@')) { - final mentionedUser = message.mentionedUsers.firstWhereOrNull( - (u) => '@${u.name}' == text, - ); - - if (mentionedUser == null) return; - - onMentionTap?.call(mentionedUser); - } else if (href != null) { - if (onLinkTap != null) { - onLinkTap!(href); - } else { - launchURL(context, href); - } - } - }, - ); - }, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart index 889271d245..ff4fc5db09 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart @@ -1,763 +1,494 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_portal/flutter_portal.dart'; -import 'package:stream_chat_flutter/platform_widget_builder/platform_widget_builder.dart'; +import 'package:stream_chat_flutter/platform_widget_builder/src/platform_widget_builder.dart'; 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/message_widget_content.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/src/message_widget/components/stream_message_leading.dart'; import 'package:stream_chat_flutter/src/misc/flexible_fractionally_sized_box.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' hide StreamMessageContent; -/// The display behaviour of a widget -enum DisplayWidget { - /// Hides the widget replacing its space with a spacer - hide, - - /// Hides the widget not replacing its space - gone, +/// A chat message widget that renders a single message with its attachments, +/// reactions, and interaction callbacks. +/// +/// [StreamMessageWidget] displays a single [Message] within a chat message +/// list. It handles the complete message layout including the author avatar, +/// message content (text, attachments, polls, quoted messages), reactions, +/// thread indicators, and user interaction gestures such as tap, long-press, +/// and context menus. +/// +/// On mobile platforms, a long-press opens the [StreamMessageActionsModal] +/// with available actions (reply, edit, delete, pin, etc.). On desktop and +/// web, those same actions appear in a right-click context menu. +/// +/// This widget delegates rendering to either a custom builder registered via +/// [StreamComponentFactory], or [DefaultStreamMessage] when no custom builder +/// is provided. Register a custom builder through [StreamChatConfigurationData] +/// to fully replace the default message layout while still receiving the same +/// [StreamMessageWidgetProps]. +/// +/// {@tool snippet} +/// +/// Display a message with default settings: +/// +/// ```dart +/// StreamMessageWidget( +/// message: message, +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Customise interaction callbacks: +/// +/// ```dart +/// StreamMessageWidget( +/// message: message, +/// onMessageTap: (msg) => print('Tapped: ${msg.id}'), +/// onThreadTap: (msg) => Navigator.push(...), +/// onUserAvatarTap: (user) => showProfile(user), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageWidgetProps], which holds every configurable property. +/// * [DefaultStreamMessage], the default implementation used when no custom +/// builder is registered. +/// * [StreamMessageActionsModal], the modal shown on long-press (mobile). +/// * [StreamMessageListView], which hosts a scrollable list of these widgets. +class StreamMessageWidget extends StatelessWidget { + /// Creates a chat message widget. + /// + /// The [message] is required. All other parameters are optional and have + /// sensible defaults resolved from the ambient theme and message data. + StreamMessageWidget({ + super.key, + required Message message, + EdgeInsetsGeometry? padding, + double? spacing, + Color? backgroundColor, + double widthFactor = 0.8, + void Function(Message)? onMessageTap, + void Function(Message)? onMessageLongPress, + void Function(User)? onUserAvatarTap, + void Function(Message message, String url)? onMessageLinkTap, + void Function(User user)? onUserMentionTap, + void Function(Message)? onThreadTap, + void Function(Message)? onReplyTap, + void Function(Message)? onReactionsTap, + void Function(Message quotedMessage)? onQuotedMessageTap, + Comparator? reactionSorting, + MessageActionsBuilder? actionsBuilder, + void Function(BuildContext, Message)? onMessageActions, + void Function(BuildContext, Message)? onBouncedErrorMessageActions, + void Function(Message)? onEditMessageTap, + List? attachmentBuilders, + }) : props = .new( + message: message, + padding: padding, + spacing: spacing, + backgroundColor: backgroundColor, + widthFactor: widthFactor, + onMessageTap: onMessageTap, + onMessageLongPress: onMessageLongPress, + onUserAvatarTap: onUserAvatarTap, + onMessageLinkTap: onMessageLinkTap, + onUserMentionTap: onUserMentionTap, + onThreadTap: onThreadTap, + onReplyTap: onReplyTap, + onReactionsTap: onReactionsTap, + onQuotedMessageTap: onQuotedMessageTap, + reactionSorting: reactionSorting, + actionsBuilder: actionsBuilder, + onMessageActions: onMessageActions, + onBouncedErrorMessageActions: onBouncedErrorMessageActions, + onEditMessageTap: onEditMessageTap, + attachmentBuilders: attachmentBuilders, + ); + + /// Creates a chat message widget from pre-built [props]. + const StreamMessageWidget.fromProps({super.key, required this.props}); + + /// The properties that configure this message widget. + final StreamMessageWidgetProps props; - /// Shows the widget normally - show, + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamMessage(props: props); + } } -/// {@template messageWidget} -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_widget.png) -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_widget_paint.png) +/// Properties for configuring a [StreamMessageWidget]. /// -/// Shows a message with reactions, replies and user avatar. +/// This class holds every configuration option for a chat message widget, +/// allowing them to be passed through the [StreamComponentFactory] when a +/// custom builder is registered. /// -/// Usually you don't use this widget as it's the default message widget used by -/// [MessageListView]. +/// Visual properties such as [padding], [spacing], and [backgroundColor] +/// override the corresponding values from [StreamMessageItemThemeData] when +/// non-null. When left null, the theme values are used instead. /// -/// The widget components render the ui based on the first ancestor of type -/// [StreamChatTheme]. -/// Modify it to change the widget appearance. -/// {@endtemplate} -class StreamMessageWidget extends StatefulWidget { - /// {@macro messageWidget} - const StreamMessageWidget({ - super.key, +/// See also: +/// +/// * [StreamMessageWidget], which uses these properties. +/// * [DefaultStreamMessage], the default implementation. +class StreamMessageWidgetProps { + /// Creates properties for a chat message widget. + const StreamMessageWidgetProps({ required this.message, - required this.messageTheme, - this.reverse = false, - this.translateUserAvatar = true, - this.shape, - this.borderSide, - this.borderRadiusGeometry, - this.attachmentShape, - this.onMentionTap, + this.padding, + this.spacing, + this.backgroundColor, + this.widthFactor = 0.8, this.onMessageTap, this.onMessageLongPress, - this.onReactionsTap, - this.onReactionsHover, - this.showReactionPicker = true, - this.showUserAvatar = DisplayWidget.show, - this.showSendingIndicator = true, - this.showThreadReplyIndicator = false, - this.showInChannelIndicator = false, - this.onReplyTap, - this.onThreadTap, - this.onEditMessageTap, - this.onConfirmDeleteTap, - this.showUsername = true, - this.showTimestamp = true, - this.showEditedLabel = true, - this.showReactions = true, - this.showDeleteMessage = true, - this.showEditMessage = true, - this.showReplyMessage = true, - this.showThreadReplyMessage = true, - this.showMarkUnreadMessage = true, - this.showResendMessage = true, - this.showCopyMessage = true, - this.showFlagButton = true, - this.showPinButton = true, - this.showPinHighlight = true, this.onUserAvatarTap, - this.onLinkTap, + this.onMessageLinkTap, + this.onUserMentionTap, + this.onThreadTap, + this.onReplyTap, + this.onReactionsTap, + this.onQuotedMessageTap, + this.reactionSorting, + this.actionsBuilder, this.onMessageActions, this.onBouncedErrorMessageActions, - this.onShowMessage, - this.userAvatarBuilder, - this.quotedMessageBuilder, - this.deletedMessageBuilder, - this.editMessageInputBuilder, - this.textBuilder, - this.bottomRowBuilderWithDefaultWidget, + this.onEditMessageTap, this.attachmentBuilders, - this.padding, - this.textPadding = const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - this.attachmentPadding = EdgeInsets.zero, - this.widthFactor = 0.78, - this.onQuotedMessageTap, - this.actionsBuilder, - this.onAttachmentTap, - this.imageAttachmentThumbnailSize = const Size(400, 400), - this.imageAttachmentThumbnailResizeType = 'clip', - this.imageAttachmentThumbnailCropType = 'center', - this.attachmentActionsModalBuilder, - this.reactionPickerBuilder = StreamReactionPicker.builder, - this.reactionIndicatorBuilder = StreamReactionIndicator.builder, }); - /// {@template onMentionTap} - /// Function called on mention tap - /// {@endtemplate} - final void Function(User)? onMentionTap; - - /// {@template onThreadTap} - /// The function called when tapping on threads - /// {@endtemplate} - final void Function(Message)? onThreadTap; - - /// {@template onReplyTap} - /// The function called when tapping on replies - /// {@endtemplate} - final void Function(Message)? onReplyTap; - - /// {@template onEditMessageTap} - /// The function called when tapping the edit action on a message. - /// If provided, the inline edit flow is used instead of the bottom sheet. - /// {@endtemplate} - final void Function(Message)? onEditMessageTap; - - /// {@template onDeleteTap} - /// The function called when delete confirmation button is tapped. - /// {@endtemplate} - final Future Function(Message)? onConfirmDeleteTap; - - /// {@template editMessageInputBuilder} - /// Widget builder for edit message layout - /// {@endtemplate} - final Widget Function(BuildContext, Message)? editMessageInputBuilder; - - /// {@template textBuilder} - /// Widget builder for building text - /// {@endtemplate} - final Widget Function(BuildContext, Message)? textBuilder; - - /// {@template onMessageActions} - /// Function called when a message is long-pressed to show actions. - /// If provided, this callback will be called instead of showing the default - /// message actions modal dialog. - /// {@endtemplate} - final void Function(BuildContext, Message)? onMessageActions; - - /// {@template onBouncedErrorMessageActions} - /// Function called when a message that has bounced with an error is long - /// pressed. If provided, this callback will be called instead of showing the - /// default bounced error message actions dialog. - /// {@endtemplate} - final void Function(BuildContext, Message)? onBouncedErrorMessageActions; - - /// {@template bottomRowBuilderWithDefaultWidget} - /// Widget builder for building a bottom row below the message. - /// Also contains the default bottom row widget. - /// {@endtemplate} - final BottomRowBuilderWithDefaultWidget? bottomRowBuilderWithDefaultWidget; - - /// {@template userAvatarBuilder} - /// Widget builder for building user avatar - /// {@endtemplate} - final Widget Function(BuildContext, User)? userAvatarBuilder; - - /// {@template quotedMessageBuilder} - /// Widget builder for building quoted message - /// {@endtemplate} - final Widget Function(BuildContext, Message)? quotedMessageBuilder; - - /// {@template deletedMessageBuilder} - /// Widget builder for building deleted message - /// {@endtemplate} - final Widget Function(BuildContext, Message)? deletedMessageBuilder; - - /// {@template message} /// The message to display. - /// {@endtemplate} final Message message; - /// {@template messageTheme} - /// The message theme - /// {@endtemplate} - final StreamMessageThemeData messageTheme; - - /// {@template reverse} - /// If true the widget will be mirrored - /// {@endtemplate} - final bool reverse; - - /// {@template shape} - /// The shape of the message text - /// {@endtemplate} - final ShapeBorder? shape; - - /// {@template attachmentShape} - /// The shape of an attachment - /// {@endtemplate} - final ShapeBorder? attachmentShape; - - /// {@template borderSide} - /// The borderSide of the message text - /// {@endtemplate} - final BorderSide? borderSide; - - /// {@template borderRadiusGeometry} - /// The border radius of the message text - /// {@endtemplate} - final BorderRadiusGeometry? borderRadiusGeometry; - - /// {@template padding} - /// The padding of the widget - /// {@endtemplate} + /// Outer padding around the entire message item. + /// + /// When non-null, takes precedence over the theme value from + /// [StreamMessageItemThemeData.padding]. + /// + /// When null (the default), the padding is determined by the theme. final EdgeInsetsGeometry? padding; - /// {@template textPadding} - /// The internal padding of the message text - /// {@endtemplate} - final EdgeInsets textPadding; + /// Horizontal spacing between the leading avatar and the content. + /// + /// When non-null, takes precedence over the theme value from + /// [StreamMessageItemThemeData.spacing]. + /// + /// When null (the default), the spacing is determined by the theme. + final double? spacing; - /// {@template attachmentPadding} - /// The internal padding of an attachment - /// {@endtemplate} - final EdgeInsetsGeometry attachmentPadding; + /// Background color for the entire message item row. + /// + /// When non-null, takes precedence over the theme value from + /// [StreamMessageItemThemeData.backgroundColor]. + /// + /// When null (the default), the background color is determined by the theme. + final Color? backgroundColor; - /// {@template widthFactor} - /// The percentage of the available width the message content should take - /// {@endtemplate} + /// Maximum width of the message content as a fraction of the parent width. + /// + /// Values should be between 0.0 and 1.0. Defaults to 0.8 when not specified. final double widthFactor; - /// {@template showUserAvatar} - /// It controls the display behaviour of the user avatar - /// {@endtemplate} - final DisplayWidget showUserAvatar; - - /// {@template showSendingIndicator} - /// It controls the display behaviour of the sending indicator - /// {@endtemplate} - final bool showSendingIndicator; - - /// {@template showReactions} - /// If `true` the message's reactions will be shown. - /// {@endtemplate} - final bool showReactions; - - /// {@template showThreadReplyIndicator} - /// If true the widget will show the thread reply indicator - /// {@endtemplate} - final bool showThreadReplyIndicator; - - /// {@template showInChannelIndicator} - /// If true the widget will show the show in channel indicator - /// {@endtemplate} - final bool showInChannelIndicator; - - /// {@template onUserAvatarTap} - /// The function called when tapping on UserAvatar - /// {@endtemplate} - final void Function(User)? onUserAvatarTap; - - /// {@template onLinkTap} - /// The function called when tapping on a link - /// {@endtemplate} - final void Function(String)? onLinkTap; - - /// {@template showReactionPicker} - /// Whether or not to show the reaction picker. - /// Used in [StreamMessageReactionsModal] and [StreamMessageActionsModal]. - /// {@endtemplate} - final bool showReactionPicker; - - /// {@template onShowMessage} - /// Callback when show message is tapped - /// {@endtemplate} - final ShowMessageCallback? onShowMessage; - - /// {@template showUsername} - /// If true show the users username next to the timestamp of the message - /// {@endtemplate} - final bool showUsername; - - /// {@template showTimestamp} - /// Show message timestamp - /// {@endtemplate} - final bool showTimestamp; - - /// {@template showTimestamp} - /// Show edited label if message is edited - /// {@endtemplate} - final bool showEditedLabel; - - /// {@template showReplyMessage} - /// Show reply action - /// {@endtemplate} - final bool showReplyMessage; - - /// {@template showThreadReplyMessage} - /// Show thread reply action - /// {@endtemplate} - final bool showThreadReplyMessage; - - /// {@template showMarkUnreadMessage} - /// Show mark unread action - /// {@endtemplate} - final bool showMarkUnreadMessage; - - /// {@template showEditMessage} - /// Show edit action - /// {@endtemplate} - final bool showEditMessage; - - /// {@template showCopyMessage} - /// Show copy action - /// {@endtemplate} - final bool showCopyMessage; - - /// {@template showDeleteMessage} - /// Show delete action - /// {@endtemplate} - final bool showDeleteMessage; - - /// {@template showResendMessage} - /// Show resend action - /// {@endtemplate} - final bool showResendMessage; - - /// {@template showFlagButton} - /// Show flag action - /// {@endtemplate} - final bool showFlagButton; - - /// {@template showPinButton} - /// Show pin action - /// {@endtemplate} - final bool showPinButton; - - /// {@template showPinHighlight} - /// Display Pin Highlight - /// {@endtemplate} - final bool showPinHighlight; - - /// {@template attachmentBuilders} - /// List of attachment builders for rendering attachment widgets pre-defined - /// and custom attachment types. + /// Called when the message is tapped. /// - /// If null, the widget will create a default list of attachment builders - /// based on the [Attachment.type] of the attachment. - /// {@endtemplate} - final List? attachmentBuilders; + /// If null, no tap gesture is registered on mobile. On desktop and web, + /// tap behaviour is unaffected because interactions are driven by the + /// context menu instead. + final void Function(Message message)? onMessageTap; - /// {@template translateUserAvatar} - /// Center user avatar with bottom of the message - /// {@endtemplate} - final bool translateUserAvatar; + /// Called when the message is long-pressed. + /// + /// If null, the default long-press behaviour is used, which opens the + /// [StreamMessageActionsModal] on mobile. Provide this callback to + /// override that behaviour entirely. + final void Function(Message message)? onMessageLongPress; - /// {@macro onQuotedMessageTap} - final OnQuotedMessageTap? onQuotedMessageTap; + /// Called when the author's avatar is tapped. + /// + /// If null, tapping the avatar has no effect. A common use is to navigate + /// to the user's profile screen. + final void Function(User user)? onUserAvatarTap; - /// {@macro onMessageTap} - final OnMessageTap? onMessageTap; + /// Called when a link is tapped in the message text. + /// + /// Receives the [Message] containing the link and the tapped URL string. + /// If null, the default link handling behaviour is used. + final void Function(Message message, String url)? onMessageLinkTap; - /// {@macro onMessageLongPress} - final OnMessageLongPress? onMessageLongPress; + /// Called when a `@mention` is tapped in the message text. + /// + /// Receives the mentioned [User] resolved from the message's + /// [Message.mentionedUsers] list. If null, tapping a mention has no effect. + final void Function(User user)? onUserMentionTap; - /// {@macro onReactionsTap} + /// Called when the thread reply indicator is tapped. /// - /// Note: Only used in mobile devices (iOS and Android). Do not confuse this - /// with the tap action on the reactions picker. - final OnReactionsTap? onReactionsTap; + /// Receives the parent [Message] of the thread. If the message was shown + /// in-channel via [Message.showInChannel], the original parent message is + /// fetched before invoking the callback. + /// + /// If null, tapping the thread indicator has no effect. + final void Function(Message message)? onThreadTap; - /// {@macro onReactionsHover} + /// Called when the quoted-reply action is selected from the actions list. + /// + /// Receives the [Message] that should be quoted. Typically used to set the + /// quoted message on the message input. /// - /// Note: Only used in desktop devices (web and desktop). - final OnReactionsHover? onReactionsHover; + /// If null, the quoted-reply action is still shown but has no effect. + final void Function(Message message)? onReplyTap; - /// {@macro messageActionsBuilder} - final MessageActionsBuilder? actionsBuilder; + /// Called when the reactions row beneath the message bubble is tapped. + /// + /// If null, the default behaviour opens a [ReactionDetailSheet] showing + /// the full list of reactions. Provide this callback to replace that + /// default with custom handling. + final void Function(Message message)? onReactionsTap; - /// {@macro onAttachmentWidgetTap} - final OnAttachmentWidgetTap? onAttachmentTap; + /// Called when an inline quoted message is tapped. + /// + /// Receives the [Message] that was quoted. Typically used to scroll to + /// the original message in the list. + /// + /// If null, tapping the quoted message has no effect. + final void Function(Message quotedMessage)? onQuotedMessageTap; - /// {@macro attachmentActionsBuilder} - final AttachmentActionsBuilder? attachmentActionsModalBuilder; + /// Controls how reaction groups are sorted when displayed. + /// + /// Defaults to [ReactionSorting.byFirstReactionAt]. + final Comparator? reactionSorting; - /// {@macro reactionPickerBuilder} - final ReactionPickerBuilder reactionPickerBuilder; + /// Allows customizing the default message actions list. + /// + /// Receives the [BuildContext] and the default list of + /// [StreamContextMenuAction] items built by the widget. Return a modified + /// list to add, remove, or reorder actions. + final MessageActionsBuilder? actionsBuilder; - /// {@macro reactionIndicatorBuilder} - final ReactionIndicatorBuilder reactionIndicatorBuilder; + /// Called when a normal message is long-pressed to show actions. + /// + /// When provided, this callback replaces the default behaviour of showing + /// the [StreamMessageActionsModal]. + final void Function(BuildContext context, Message message)? onMessageActions; - /// Size of the image attachment thumbnail. - final Size imageAttachmentThumbnailSize; + /// Called when a bounced-error message is long-pressed. + /// + /// When provided, this callback replaces the default behaviour of showing + /// the [ModeratedMessageActionsModal]. + final void Function(BuildContext context, Message message)? onBouncedErrorMessageActions; - /// Resize type of the image attachment thumbnail. + /// Called when the edit-message action is selected. /// - /// Defaults to [crop] - final String /*clip|crop|scale|fill*/ imageAttachmentThumbnailResizeType; + /// When provided, this callback replaces the default behaviour of showing + /// the edit-message bottom sheet via [showEditMessageSheet]. + final void Function(Message message)? onEditMessageTap; - /// Crop type of the image attachment thumbnail. + /// Custom attachment builders for rendering message attachments. /// - /// Defaults to [center] - final String /*center|top|bottom|left|right*/ imageAttachmentThumbnailCropType; - - /// {@template copyWith} - /// Creates a copy of [StreamMessageWidget] with specified attributes - /// overridden. - /// {@endtemplate} - StreamMessageWidget copyWith({ - Key? key, - void Function(User)? onMentionTap, - void Function(Message)? onThreadTap, - void Function(Message)? onReplyTap, - void Function(Message)? onEditMessageTap, - Future Function(Message)? onConfirmDeleteTap, - Widget Function(BuildContext, Message)? editMessageInputBuilder, - Widget Function(BuildContext, Message)? textBuilder, - Widget Function(BuildContext, Message)? quotedMessageBuilder, - Widget Function(BuildContext, Message)? deletedMessageBuilder, - BottomRowBuilderWithDefaultWidget? bottomRowBuilderWithDefaultWidget, - void Function(BuildContext, Message)? onMessageActions, - void Function(BuildContext, Message)? onBouncedErrorMessageActions, + /// When non-null, these builders are used instead of the default ones + /// provided by [StreamChatConfigurationData.attachmentBuilders]. + /// + /// Custom builders are prepended to the default builder list, so they take + /// priority for attachment types they can handle. + final List? attachmentBuilders; + + /// Returns a copy of this [StreamMessageWidgetProps] with the given fields + /// replaced with new values. + StreamMessageWidgetProps copyWith({ Message? message, - StreamMessageThemeData? messageTheme, - bool? reverse, - ShapeBorder? shape, - ShapeBorder? attachmentShape, - BorderSide? borderSide, - BorderRadiusGeometry? borderRadiusGeometry, EdgeInsetsGeometry? padding, - EdgeInsets? textPadding, - EdgeInsetsGeometry? attachmentPadding, + double? spacing, + Color? backgroundColor, double? widthFactor, - DisplayWidget? showUserAvatar, - bool? showSendingIndicator, - bool? showReactions, - bool? allRead, - bool? showThreadReplyIndicator, - bool? showInChannelIndicator, + void Function(Message)? onMessageTap, + void Function(Message)? onMessageLongPress, void Function(User)? onUserAvatarTap, - void Function(String)? onLinkTap, - bool? showReactionBrowser, - bool? showReactionPicker, - List? readList, - ShowMessageCallback? onShowMessage, - bool? showUsername, - bool? showTimestamp, - bool? showEditedLabel, - bool? showReplyMessage, - bool? showThreadReplyMessage, - bool? showEditMessage, - bool? showCopyMessage, - bool? showDeleteMessage, - bool? showResendMessage, - bool? showFlagButton, - bool? showPinButton, - bool? showPinHighlight, - bool? showMarkUnreadMessage, - List? attachmentBuilders, - bool? translateUserAvatar, - OnQuotedMessageTap? onQuotedMessageTap, - OnMessageTap? onMessageTap, - OnMessageLongPress? onMessageLongPress, - OnReactionsTap? onReactionsTap, - OnReactionsHover? onReactionsHover, + void Function(Message, String)? onMessageLinkTap, + void Function(User)? onUserMentionTap, + void Function(Message)? onThreadTap, + void Function(Message)? onReplyTap, + void Function(Message)? onReactionsTap, + void Function(Message)? onQuotedMessageTap, + Comparator? reactionSorting, MessageActionsBuilder? actionsBuilder, - OnAttachmentWidgetTap? onAttachmentTap, - Widget Function(BuildContext, User)? userAvatarBuilder, - Size? imageAttachmentThumbnailSize, - String? imageAttachmentThumbnailResizeType, - String? imageAttachmentThumbnailCropType, - AttachmentActionsBuilder? attachmentActionsModalBuilder, - ReactionPickerBuilder? reactionPickerBuilder, - ReactionIndicatorBuilder? reactionIndicatorBuilder, + void Function(BuildContext, Message)? onMessageActions, + void Function(BuildContext, Message)? onBouncedErrorMessageActions, + void Function(Message)? onEditMessageTap, + List? attachmentBuilders, }) { - return StreamMessageWidget( - key: key ?? this.key, - onMentionTap: onMentionTap ?? this.onMentionTap, - onThreadTap: onThreadTap ?? this.onThreadTap, - onReplyTap: onReplyTap ?? this.onReplyTap, - onEditMessageTap: onEditMessageTap ?? this.onEditMessageTap, - onConfirmDeleteTap: onConfirmDeleteTap ?? this.onConfirmDeleteTap, - editMessageInputBuilder: editMessageInputBuilder ?? this.editMessageInputBuilder, - textBuilder: textBuilder ?? this.textBuilder, - quotedMessageBuilder: quotedMessageBuilder ?? this.quotedMessageBuilder, - deletedMessageBuilder: deletedMessageBuilder ?? this.deletedMessageBuilder, - bottomRowBuilderWithDefaultWidget: bottomRowBuilderWithDefaultWidget ?? this.bottomRowBuilderWithDefaultWidget, - onMessageActions: onMessageActions ?? this.onMessageActions, - onBouncedErrorMessageActions: onBouncedErrorMessageActions ?? this.onBouncedErrorMessageActions, + return StreamMessageWidgetProps( message: message ?? this.message, - messageTheme: messageTheme ?? this.messageTheme, - reverse: reverse ?? this.reverse, - shape: shape ?? this.shape, - attachmentShape: attachmentShape ?? this.attachmentShape, - borderSide: borderSide ?? this.borderSide, - borderRadiusGeometry: borderRadiusGeometry ?? this.borderRadiusGeometry, padding: padding ?? this.padding, - textPadding: textPadding ?? this.textPadding, - attachmentPadding: attachmentPadding ?? this.attachmentPadding, + spacing: spacing ?? this.spacing, + backgroundColor: backgroundColor ?? this.backgroundColor, widthFactor: widthFactor ?? this.widthFactor, - showUserAvatar: showUserAvatar ?? this.showUserAvatar, - showSendingIndicator: showSendingIndicator ?? this.showSendingIndicator, - showEditedLabel: showEditedLabel ?? this.showEditedLabel, - showReactions: showReactions ?? this.showReactions, - showThreadReplyIndicator: showThreadReplyIndicator ?? this.showThreadReplyIndicator, - showInChannelIndicator: showInChannelIndicator ?? this.showInChannelIndicator, - onUserAvatarTap: onUserAvatarTap ?? this.onUserAvatarTap, - onLinkTap: onLinkTap ?? this.onLinkTap, - showReactionPicker: showReactionPicker ?? this.showReactionPicker, - onShowMessage: onShowMessage ?? this.onShowMessage, - showUsername: showUsername ?? this.showUsername, - showTimestamp: showTimestamp ?? this.showTimestamp, - showReplyMessage: showReplyMessage ?? this.showReplyMessage, - showThreadReplyMessage: showThreadReplyMessage ?? this.showThreadReplyMessage, - showEditMessage: showEditMessage ?? this.showEditMessage, - showCopyMessage: showCopyMessage ?? this.showCopyMessage, - showDeleteMessage: showDeleteMessage ?? this.showDeleteMessage, - showResendMessage: showResendMessage ?? this.showResendMessage, - showFlagButton: showFlagButton ?? this.showFlagButton, - showPinButton: showPinButton ?? this.showPinButton, - showPinHighlight: showPinHighlight ?? this.showPinHighlight, - showMarkUnreadMessage: showMarkUnreadMessage ?? this.showMarkUnreadMessage, - attachmentBuilders: attachmentBuilders ?? this.attachmentBuilders, - translateUserAvatar: translateUserAvatar ?? this.translateUserAvatar, - onQuotedMessageTap: onQuotedMessageTap ?? this.onQuotedMessageTap, onMessageTap: onMessageTap ?? this.onMessageTap, onMessageLongPress: onMessageLongPress ?? this.onMessageLongPress, + onUserAvatarTap: onUserAvatarTap ?? this.onUserAvatarTap, + onMessageLinkTap: onMessageLinkTap ?? this.onMessageLinkTap, + onUserMentionTap: onUserMentionTap ?? this.onUserMentionTap, + onThreadTap: onThreadTap ?? this.onThreadTap, + onReplyTap: onReplyTap ?? this.onReplyTap, onReactionsTap: onReactionsTap ?? this.onReactionsTap, - onReactionsHover: onReactionsHover ?? this.onReactionsHover, + onQuotedMessageTap: onQuotedMessageTap ?? this.onQuotedMessageTap, + reactionSorting: reactionSorting ?? this.reactionSorting, actionsBuilder: actionsBuilder ?? this.actionsBuilder, - onAttachmentTap: onAttachmentTap ?? this.onAttachmentTap, - userAvatarBuilder: userAvatarBuilder ?? this.userAvatarBuilder, - imageAttachmentThumbnailSize: imageAttachmentThumbnailSize ?? this.imageAttachmentThumbnailSize, - imageAttachmentThumbnailResizeType: imageAttachmentThumbnailResizeType ?? this.imageAttachmentThumbnailResizeType, - imageAttachmentThumbnailCropType: imageAttachmentThumbnailCropType ?? this.imageAttachmentThumbnailCropType, - attachmentActionsModalBuilder: attachmentActionsModalBuilder ?? this.attachmentActionsModalBuilder, - reactionPickerBuilder: reactionPickerBuilder ?? this.reactionPickerBuilder, - reactionIndicatorBuilder: reactionIndicatorBuilder ?? this.reactionIndicatorBuilder, + onMessageActions: onMessageActions ?? this.onMessageActions, + onBouncedErrorMessageActions: onBouncedErrorMessageActions ?? this.onBouncedErrorMessageActions, + onEditMessageTap: onEditMessageTap ?? this.onEditMessageTap, + attachmentBuilders: attachmentBuilders ?? this.attachmentBuilders, ); } - - @override - _StreamMessageWidgetState createState() => _StreamMessageWidgetState(); } -class _StreamMessageWidgetState extends State - with AutomaticKeepAliveClientMixin { - bool get showThreadReplyIndicator => widget.showThreadReplyIndicator; - - bool get showSendingIndicator => widget.showSendingIndicator; - - bool get isDeleted => widget.message.isDeleted; - - bool get showUsername => widget.showUsername; - - bool get showTimeStamp => widget.showTimestamp; - - bool get showEditedLabel => widget.showEditedLabel; - - bool get isTextEdited => widget.message.messageTextUpdatedAt != null; - - bool get showInChannel => widget.showInChannelIndicator; - - /// {@template hasQuotedMessage} - /// `true` if [StreamMessageWidget.quotedMessage] is not null. - /// {@endtemplate} - bool get hasQuotedMessage => widget.message.quotedMessage != null; - - bool get isSendFailed => widget.message.state.isSendingFailed; +/// The default implementation of [StreamMessageWidget]. +/// +/// Composes a full message row with an author avatar, content bubble, +/// header annotations, footer metadata, and platform-adaptive interaction +/// handling (tap and long-press on mobile, right-click context menu on +/// desktop and web). +/// +/// Message actions can be customised through +/// [StreamMessageWidgetProps.actionsBuilder]. +/// +/// See also: +/// +/// * [StreamMessageWidget], the public API widget. +/// * [StreamMessageWidgetProps], which configures this widget. +/// * [StreamMessageItemTheme], provides theme data to this widget. +class DefaultStreamMessage extends StatelessWidget { + /// Creates a default chat message widget with the given [props]. + const DefaultStreamMessage({super.key, required this.props}); - bool get isUpdateFailed => widget.message.state.isUpdatingFailed; + /// The properties that configure this widget. + final StreamMessageWidgetProps props; - bool get isDeleteFailed => widget.message.state.isDeletingFailed; + @override + Widget build(BuildContext context) { + final message = props.message; - bool get isBouncedWithError => widget.message.isBouncedWithError; + final placement = StreamMessagePlacement.of(context); + final theme = StreamMessageItemTheme.of(context); + final defaults = _StreamMessageWidgetDefaults(context, isPinned: message.pinned, state: message.state); - /// {@template isFailedState} - /// Whether the message has failed to be sent, updated, deleted or is bounced - /// back with the message type as error. - /// {@endtemplate} - bool get isFailedState => isSendFailed || isUpdateFailed || isDeleteFailed || isBouncedWithError; + final resolve = StreamMessageStyleResolver(placement, [theme, defaults]); - /// {@template isGiphy} - /// `true` if any of the [message]'s attachments are a giphy. - /// {@endtemplate} - bool get isGiphy => widget.message.attachments.any((element) => element.type == AttachmentType.giphy); + final effectivePadding = props.padding ?? theme.padding ?? defaults.padding; + final effectiveSpacing = props.spacing ?? theme.spacing ?? defaults.spacing; + final effectiveBackgroundColor = props.backgroundColor ?? theme.backgroundColor ?? defaults.backgroundColor; + final effectiveLeadingVisibility = resolve((theme) => theme?.leadingVisibility); + final effectiveHeaderVisibility = resolve((theme) => theme?.headerVisibility); + final effectiveFooterVisibility = resolve((theme) => theme?.footerVisibility); - /// {@template isOnlyEmoji} - /// `true` if [message.text] contains only emoji. - /// {@endtemplate} - bool get isOnlyEmoji => widget.message.text?.isOnlyEmoji == true; + Widget? leadingWidget; + if (props.message.user case final user?) { + final effectiveAvatarSize = theme.avatarSize ?? defaults.avatarSize; - /// {@template hasNonUrlAttachments} - /// `true` if any of the [message]'s attachments are a giphy and do not - /// have a [Attachment.titleLink]. - /// {@endtemplate} - bool get hasNonUrlAttachments => widget.message.attachments.any((it) => it.type != AttachmentType.urlPreview); + leadingWidget = effectiveLeadingVisibility.apply( + StreamAvatarTheme( + data: .new(size: effectiveAvatarSize), + child: StreamMessageLeading(author: user), + ), + ); + } - /// {@template hasUrlAttachments} - /// `true` if any of the [message]'s attachments are a giphy with a - /// [Attachment.titleLink]. - /// {@endtemplate} - bool get hasUrlAttachments => widget.message.attachments.any((it) => it.type == AttachmentType.urlPreview); + final headerWidget = effectiveHeaderVisibility.apply( + streamMessageHeader( + context: context, + message: message, + onViewChannelTap: () => _onViewThread(context, message), + ), + ); + final footerWidget = effectiveFooterVisibility.apply(StreamMessageFooter(message: message)); - /// {@template showBottomRow} - /// Show the [BottomRow] widget if any of the following are `true`: - /// * [StreamMessageWidget.showThreadReplyIndicator] - /// * [StreamMessageWidget.showUsername] - /// * [StreamMessageWidget.showTimestamp] - /// * [StreamMessageWidget.showInChannelIndicator] - /// * [StreamMessageWidget.showSendingIndicator] - /// * [StreamMessageWidget.message.isDeleted] - /// {@endtemplate} - bool get showBottomRow => - showThreadReplyIndicator || - showUsername || - showTimeStamp || - showInChannel || - showSendingIndicator || - isTextEdited; + final contentWidget = StreamMessageContent( + message: message, + header: headerWidget, + footer: footerWidget, + attachmentBuilders: props.attachmentBuilders, + reactionSorting: props.reactionSorting, + onQuotedMessageTap: props.onQuotedMessageTap, + onRepliesTap: () => _onViewThread(context, message), + onLinkTap: (_, href, __) { + if (href == null) return; + if (props.onMessageLinkTap case final onTap?) return onTap(message, href); + return launchURL(context, href).ignore(); + }, + onMentionTap: switch (props.onUserMentionTap) { + final onTap? => (_, id) { + final user = message.mentionedUsers.firstWhereOrNull((u) => u.id == id); + if (user != null) onTap(user); + }, + _ => null, + }, + onReactionsTap: switch (props.onReactionsTap) { + final onReactionsTap? => () => onReactionsTap(message), + _ => () => _showMessageReactionsModal(context, message), + }, + ); - /// {@template isPinned} - /// Whether [StreamMessageWidget.message] is pinned or not. - /// {@endtemplate} - bool get isPinned => widget.message.pinned && !widget.message.isDeleted; + return Material( + animateColor: true, + color: effectiveBackgroundColor, + child: PlatformWidgetBuilder( + mobile: (context, child) => InkWell( + onTap: switch (props.onMessageTap) { + final onMessageTap? => () => onMessageTap(message), + _ => null, + }, + onLongPress: switch (props.onMessageLongPress) { + final onMessageLongPress? => () => onMessageLongPress(message), + _ when message.state.isDeleted => null, + _ when message.state.isOutgoing => null, + _ => () => _onMessageLongPressed(context, message), + }, + child: child, + ), + desktopOrWeb: (context, child) { + final messageState = message.state; - /// {@template shouldShowReactions} - /// Should show message reactions if [StreamMessageWidget.showReactions] is - /// `true`, if there are reactions to show, and if the message is not deleted. - /// {@endtemplate} - bool get shouldShowReactions => - widget.showReactions && (widget.message.reactionGroups?.isNotEmpty == true) && !widget.message.isDeleted; + // If the message is deleted or not yet sent, we don't want to + // show any context menu actions. + if (messageState.isDeleted || messageState.isOutgoing) return child; - @override - bool get wantKeepAlive => widget.message.attachments.isNotEmpty; + final channel = StreamChannel.of(context).channel; + final menuItems = _buildDesktopOrWebActions(context, message); + if (menuItems.isEmpty) return MouseRegion(child: child); - @override - Widget build(BuildContext context) { - super.build(context); - final theme = StreamChatTheme.of(context); - final streamChat = StreamChat.of(context); - - final avatarWidth = widget.messageTheme.avatarTheme?.constraints.maxWidth ?? 40; - final bottomRowPadding = widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5; - - return Portal( - child: Material( - color: switch (isPinned && widget.showPinHighlight) { - true => theme.colorTheme.highlight, - false => Colors.transparent, - }, - child: PlatformWidgetBuilder( - mobile: (context, child) { - final message = widget.message; - return InkWell( - onTap: switch (widget.onMessageTap) { - final onTap? => () => onTap(message), - _ => null, - }, - onLongPress: switch (widget.onMessageLongPress) { - final onLongPress? => () => onLongPress(message), - // If the message is not yet sent or deleted, we don't want - // to handle long press events by default. - _ when message.state.isDeleted => null, - _ when message.state.isOutgoing => null, - _ => () => _onMessageLongPressed(context, message), - }, - child: child, - ); - }, - desktopOrWeb: (context, child) { - final message = widget.message; - final messageState = message.state; - - // If the message is deleted or not yet sent, we don't want to - // show any context menu actions. - if (messageState.isDeleted || messageState.isOutgoing) return child; - - final channel = StreamChannel.of(context).channel; - final menuItems = _buildDesktopOrWebActions(context, message); - if (menuItems.isEmpty) return MouseRegion(child: child); - - return ContextMenuRegion( - onSelected: (result) { - if (result is! MessageAction) return; - return _onActionTap(context, channel, result).ignore(); - }, - menuBuilder: (_, anchor) => ContextMenu( - anchor: anchor, - menuItems: menuItems, - ), - child: MouseRegion(child: child), - ); - }, - child: FlexibleFractionallySizedBox( - widthFactor: widget.widthFactor, - alignment: switch (widget.reverse) { - true => AlignmentDirectional.centerEnd, - false => AlignmentDirectional.centerStart, + return ContextMenuRegion( + onSelected: (result) { + if (result is! MessageAction) return; + return _onActionTap(context, channel, result).ignore(); }, - child: Padding( - padding: widget.padding ?? const EdgeInsets.symmetric(horizontal: 16), - child: MessageWidgetContent( - streamChatTheme: theme, - showUsername: showUsername, - showTimeStamp: showTimeStamp, - showEditedLabel: showEditedLabel, - showThreadReplyIndicator: showThreadReplyIndicator, - showSendingIndicator: showSendingIndicator, - showInChannel: showInChannel, - isGiphy: isGiphy, - isOnlyEmoji: isOnlyEmoji, - hasUrlAttachments: hasUrlAttachments, - messageTheme: widget.messageTheme, - reverse: widget.reverse, - message: widget.message, - hasNonUrlAttachments: hasNonUrlAttachments, - hasQuotedMessage: hasQuotedMessage, - textPadding: widget.textPadding, - attachmentBuilders: widget.attachmentBuilders, - attachmentPadding: widget.attachmentPadding, - attachmentShape: widget.attachmentShape, - onAttachmentTap: widget.onAttachmentTap, - onReplyTap: widget.onReplyTap, - onThreadTap: widget.onThreadTap, - onShowMessage: widget.onShowMessage, - attachmentActionsModalBuilder: widget.attachmentActionsModalBuilder, - avatarWidth: avatarWidth, - bottomRowPadding: bottomRowPadding, - isFailedState: isFailedState, - isPinned: isPinned, - messageWidget: widget, - showBottomRow: showBottomRow, - showPinHighlight: widget.showPinHighlight, - showReactions: shouldShowReactions, - onReactionsTap: () { - final message = widget.message; - return switch (widget.onReactionsTap) { - final onReactionsTap? => onReactionsTap(message), - _ => _showMessageReactionsModal(context, message), - }; - }, - onReactionsHover: widget.onReactionsHover, - showUserAvatar: widget.showUserAvatar, - streamChat: streamChat, - translateUserAvatar: widget.translateUserAvatar, - shape: widget.shape, - borderSide: widget.borderSide, - borderRadiusGeometry: widget.borderRadiusGeometry, - textBuilder: widget.textBuilder, - quotedMessageBuilder: widget.quotedMessageBuilder, - deletedMessageBuilder: widget.deletedMessageBuilder, - onLinkTap: widget.onLinkTap, - onMentionTap: widget.onMentionTap, - onQuotedMessageTap: widget.onQuotedMessageTap, - bottomRowBuilderWithDefaultWidget: widget.bottomRowBuilderWithDefaultWidget, - onUserAvatarTap: widget.onUserAvatarTap, - userAvatarBuilder: widget.userAvatarBuilder, - reactionIndicatorBuilder: widget.reactionIndicatorBuilder, - ), + menuBuilder: (_, anchor) => ContextMenu( + anchor: anchor, + menuItems: menuItems, + ), + child: MouseRegion(child: child), + ); + }, + child: FlexibleFractionallySizedBox( + widthFactor: props.widthFactor, + alignment: StreamMessagePlacement.alignmentDirectionalOf(context), + child: Padding( + padding: effectivePadding, + child: Row( + mainAxisSize: .min, + spacing: effectiveSpacing, + crossAxisAlignment: .end, + children: [ + ?leadingWidget, + Flexible(child: contentWidget), + ], ), ), ), @@ -765,6 +496,7 @@ class _StreamMessageWidgetState extends State ); } + // Builds the action list for a bounced (moderation-error) message. List _buildBouncedErrorMessageActions({ required BuildContext context, required Message message, @@ -775,51 +507,40 @@ class _StreamMessageWidgetState extends State ); } + // Builds the standard action list, applying the custom actionsBuilder if set. List _buildMessageActions({ required BuildContext context, required Message message, required Channel channel, OwnUser? currentUser, }) { - final actions = - StreamMessageActionsBuilder.buildActions( - context: context, - message: message, - channel: channel, - currentUser: currentUser, - )..retainWhere( - (it) => switch (it.props.value) { - QuotedReply() => widget.showReplyMessage, - ThreadReply() => widget.showThreadReplyMessage, - MarkUnread() => widget.showMarkUnreadMessage, - ResendMessage() => widget.showResendMessage, - EditMessage() => widget.showEditMessage, - CopyMessage() => widget.showCopyMessage, - FlagMessage() => widget.showFlagButton, - PinMessage() => widget.showPinButton, - DeleteMessage() => widget.showDeleteMessage, - _ => true, // Retain all the remaining actions. - }, - ); + final actions = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + ); - if (widget.actionsBuilder case final builder?) { + if (props.actionsBuilder case final builder?) { return builder(context, actions); } return StreamContextMenuAction.partitioned(items: actions); } + // Dispatches to bounced-error or normal actions for desktop/web. List _buildDesktopOrWebActions( BuildContext context, Message message, ) { - if (isBouncedWithError) { + if (message.isBouncedWithError) { return _buildBouncedErrorMessageDesktopOrWebActions(context, message); } return _buildMessageDesktopOrWebActions(context, message); } + // Builds partitioned bounced-error actions for the desktop/web context menu. List _buildBouncedErrorMessageDesktopOrWebActions( BuildContext context, Message message, @@ -832,13 +553,14 @@ class _StreamMessageWidgetState extends State return StreamContextMenuAction.partitioned(items: actions); } + // Builds normal actions + reaction picker for the desktop/web context menu. List _buildMessageDesktopOrWebActions( BuildContext context, Message message, ) { final channel = StreamChannel.of(context).channel; final currentUser = channel.client.state.currentUser; - final showPicker = widget.showReactionPicker && channel.canSendReaction; + final showPicker = channel.canSendReaction; final actions = _buildMessageActions( context: context, @@ -853,11 +575,12 @@ class _StreamMessageWidgetState extends State } return [ - if (showPicker) widget.reactionPickerBuilder(context, message, onReactionPicked), + if (showPicker) StreamReactionPicker.builder(context, message, onReactionPicked), ...actions, ]; } + // Opens the reaction detail sheet and handles the returned action. Future _showMessageReactionsModal( BuildContext context, Message message, @@ -873,28 +596,49 @@ class _StreamMessageWidgetState extends State return _onActionTap(context, channel, action).ignore(); } + // Resolves the thread parent (fetching if shown in-channel) and invokes + // the onThreadTap callback. + Future _onViewThread( + BuildContext context, + Message message, + ) async { + try { + var threadMessage = message; + if (message.showInChannel case true) { + final streamChannel = StreamChannel.of(context); + threadMessage = await streamChannel.getMessage(message.parentId!); + } + return props.onThreadTap?.call(threadMessage); + } catch (e, stk) { + debugPrint('Error while fetching message: $e, $stk'); + } + } + + // Routes a long-press to bounced-error or normal actions handler. Future _onMessageLongPressed( BuildContext context, Message message, ) { - if (isBouncedWithError) { + if (message.isBouncedWithError) { return _onBouncedErrorMessageActions(context, message); } return _onMessageActions(context, message); } + // Delegates to the custom callback or falls back to the default dialog. Future _onBouncedErrorMessageActions( BuildContext context, Message message, ) async { - if (widget.onBouncedErrorMessageActions case final onActions?) { + if (props.onBouncedErrorMessageActions case final onActions?) { return onActions(context, message); } return _showBouncedErrorMessageActionsDialog(context, message); } + // Shows the ModeratedMessageActionsModal for a bounced-error message. Future _showBouncedErrorMessageActionsDialog( BuildContext context, Message message, @@ -919,24 +663,26 @@ class _StreamMessageWidgetState extends State return _onActionTap(context, channel, action).ignore(); } + // Delegates to the custom callback or falls back to the default modal. Future _onMessageActions( BuildContext context, Message message, ) async { - if (widget.onMessageActions case final onActions?) { + if (props.onMessageActions case final onActions?) { return onActions(context, message); } return _showMessageActionModalDialog(context, message); } + // Shows the StreamMessageActionsModal with a reaction picker and actions. Future _showMessageActionModalDialog( BuildContext context, Message message, ) async { final channel = StreamChannel.of(context).channel; final currentUser = channel.client.state.currentUser; - final showPicker = widget.showReactionPicker && channel.canSendReaction; + final showPicker = channel.canSendReaction; final actions = _buildMessageActions( context: context, @@ -949,31 +695,21 @@ class _StreamMessageWidgetState extends State context: context, useRootNavigator: false, builder: (_) => StreamChatConfiguration( - // This is needed to provide the nearest reaction icons to the - // StreamMessageActionsModal. data: StreamChatConfiguration.of(context), - child: StreamMessageActionsModal( - message: message, - reverse: widget.reverse, - messageActions: actions, - showReactionPicker: showPicker, - reactionPickerBuilder: widget.reactionPickerBuilder, - messageWidget: StreamChannel( - channel: channel, - child: widget.copyWith( - key: const Key('MessageWidget'), - message: message.trimmed, - showReactions: false, - showUsername: false, - showTimestamp: false, - translateUserAvatar: false, - showSendingIndicator: false, - padding: EdgeInsets.zero, - showPinHighlight: false, - showUserAvatar: switch (widget.reverse) { - true => DisplayWidget.gone, - false => DisplayWidget.show, - }, + child: StreamMessagePlacement( + data: StreamMessagePlacement.of(context), + child: StreamMessageActionsModal( + message: message, + messageActions: actions, + showReactionPicker: showPicker, + messageWidget: StreamChannel( + channel: channel, + child: StreamMessageWidget( + key: const Key('MessageWidget'), + message: message.trimmed, + padding: EdgeInsets.zero, + backgroundColor: StreamColors.transparent, + ), ), ), ), @@ -984,29 +720,29 @@ class _StreamMessageWidgetState extends State return _onActionTap(context, channel, action).ignore(); } + // Dispatches a MessageAction to the appropriate channel or callback handler. Future _onActionTap( BuildContext context, Channel channel, MessageAction action, - ) async { - return switch (action) { - SelectReaction() => _selectReaction(context, action.message, channel, action.reaction), - CopyMessage() => _copyMessage(action.message, channel), - DeleteMessage() => _maybeDeleteMessage(context, action.message, channel), - HardDeleteMessage() => channel.deleteMessage(action.message, hard: true), - EditMessage() => _editMessage(context, action.message, channel), - FlagMessage() => _maybeFlagMessage(context, action.message, channel), - MarkUnread() => channel.markUnread(action.message.id), - MuteUser() => channel.client.muteUser(action.user.id), - UnmuteUser() => channel.client.unmuteUser(action.user.id), - PinMessage() => channel.pinMessage(action.message), - UnpinMessage() => channel.unpinMessage(action.message), - ResendMessage() => channel.retryMessage(action.message), - QuotedReply() => widget.onReplyTap?.call(action.message), - ThreadReply() => widget.onThreadTap?.call(action.message), - }; - } - + ) async => switch (action) { + SelectReaction() => _selectReaction(context, action.message, channel, action.reaction), + CopyMessage() => _copyMessage(action.message, channel), + DeleteMessage() => _maybeDeleteMessage(context, action.message, channel), + HardDeleteMessage() => channel.deleteMessage(action.message, hard: true), + EditMessage() => props.onEditMessageTap?.call(action.message), + FlagMessage() => _maybeFlagMessage(context, action.message, channel), + MarkUnread() => channel.markUnread(action.message.id), + MuteUser() => channel.client.muteUser(action.user.id), + UnmuteUser() => channel.client.unmuteUser(action.user.id), + PinMessage() => channel.pinMessage(action.message), + UnpinMessage() => channel.unpinMessage(action.message), + ResendMessage() => channel.retryMessage(action.message), + QuotedReply() => props.onReplyTap?.call(action.message), + ThreadReply() => props.onThreadTap?.call(action.message), + }; + + // Copies the message text (with mentions replaced) to the clipboard. Future _copyMessage( Message message, Channel channel, @@ -1019,6 +755,7 @@ class _StreamMessageWidgetState extends State return Clipboard.setData(ClipboardData(text: messageText)); } + // Shows a confirmation dialog before deleting the message. Future _maybeDeleteMessage( BuildContext context, Message message, @@ -1040,24 +777,7 @@ class _StreamMessageWidgetState extends State return channel.deleteMessage(message); } - Future _editMessage( - BuildContext context, - Message message, - Channel channel, - ) { - final onEditMessageTap = widget.onEditMessageTap; - if (onEditMessageTap != null) { - onEditMessageTap(message); - return Future.value(); - } - return showEditMessageSheet( - context: context, - channel: channel, - message: message, - editMessageInputBuilder: widget.editMessageInputBuilder, - ); - } - + // Shows a confirmation dialog before flagging the message. Future _maybeFlagMessage( BuildContext context, Message message, @@ -1080,6 +800,7 @@ class _StreamMessageWidgetState extends State return channel.client.flagMessage(messageId); } + // Toggles a reaction: removes it if already present, otherwise sends it. Future _selectReaction( BuildContext context, Message message, @@ -1104,7 +825,9 @@ class _StreamMessageWidgetState extends State } } +// Truncates long message text for display in the actions modal preview. extension on Message { + // Returns a copy with text and nested content truncated to 100 characters. Message get trimmed { final trimmedText = switch (text) { final text? when text.length > 100 => '${text.substring(0, 100)}...', @@ -1119,7 +842,9 @@ extension on Message { } } +// Truncates long poll names for display in the actions modal preview. extension on Poll { + // Returns a copy with name truncated to 100 characters. Poll get trimmed { final trimmedName = switch (name) { final name when name.length > 100 => '${name.substring(0, 100)}...', @@ -1129,3 +854,58 @@ extension on Poll { return copyWith(name: trimmedName); } } + +// Built-in fallback theme values for [DefaultStreamMessage]. +// +// Used when neither the explicit props nor the ambient +// [StreamMessageItemThemeData] provide a value for a given property. +class _StreamMessageWidgetDefaults extends StreamMessageItemThemeData { + _StreamMessageWidgetDefaults( + this._context, { + this.isPinned = false, + required MessageState state, + }) : _messageState = state; + + final bool isPinned; + + final BuildContext _context; + final MessageState _messageState; + + late final StreamSpacing _spacing = _context.streamSpacing; + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + + @override + double get spacing => _spacing.xs; + + @override + StreamAvatarSize get avatarSize => .md; + + @override + EdgeInsetsGeometry get padding => .symmetric(horizontal: _spacing.md); + + @override + Color? get backgroundColor { + if (isPinned && !_messageState.isDeleted) return _colorScheme.backgroundHighlight; + return StreamColors.transparent; + } + + @override + StreamMessageStyleVisibility get leadingVisibility => .resolveWith( + (placement) => switch ((placement.channelKind, placement.alignment, placement.stackPosition)) { + (.direct, _, _) || (_, .end, _) => .gone, + (_, _, .top || .middle) => .hidden, + (_, _, .single || .bottom) => .visible, + }, + ); + + @override + StreamMessageStyleVisibility get headerVisibility => .all(.visible); + + @override + StreamMessageStyleVisibility get footerVisibility => .resolveWith( + (placement) => switch (placement.stackPosition) { + .single || .bottom => .visible, + _ => .gone, + }, + ); +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart deleted file mode 100644 index 1079cedd83..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart +++ /dev/null @@ -1,446 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:meta/meta.dart'; -import 'package:stream_chat_flutter/src/reactions/desktop_reactions_builder.dart'; -import 'package:stream_chat_flutter/src/reactions/indicator/reaction_indicator_bubble_overlay.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// Signature for the builder function that will be called when the message -/// bottom row is built. Includes the [Message]. -typedef BottomRowBuilder = Widget Function(BuildContext, Message); - -/// Signature for the builder function that will be called when the message -/// bottom row is built. Includes the [Message] and the default [BottomRow]. -typedef BottomRowBuilderWithDefaultWidget = - Widget Function( - BuildContext, - Message, - BottomRow, - ); - -/// {@template messageWidgetContent} -/// The main content of a [StreamMessageWidget]. -/// -/// Should not be used outside of [MessageWidget. -/// {@endtemplate} -@internal -class MessageWidgetContent extends StatelessWidget { - /// {@macro messageWidgetContent} - const MessageWidgetContent({ - super.key, - required this.reverse, - required this.isPinned, - required this.showPinHighlight, - required this.showBottomRow, - required this.message, - required this.showUserAvatar, - required this.avatarWidth, - required this.showReactions, - required this.onReactionsTap, - required this.onReactionsHover, - required this.messageTheme, - required this.streamChatTheme, - required this.isFailedState, - required this.hasQuotedMessage, - required this.hasUrlAttachments, - required this.hasNonUrlAttachments, - required this.isOnlyEmoji, - required this.isGiphy, - required this.attachmentBuilders, - required this.attachmentPadding, - required this.attachmentShape, - required this.onAttachmentTap, - required this.onShowMessage, - required this.onReplyTap, - required this.attachmentActionsModalBuilder, - required this.textPadding, - required this.translateUserAvatar, - required this.bottomRowPadding, - required this.showInChannel, - required this.streamChat, - required this.showSendingIndicator, - required this.showThreadReplyIndicator, - required this.showTimeStamp, - required this.showUsername, - required this.showEditedLabel, - required this.messageWidget, - required this.onThreadTap, - required this.reactionIndicatorBuilder, - this.onUserAvatarTap, - this.borderRadiusGeometry, - this.borderSide, - this.shape, - this.onQuotedMessageTap, - this.onMentionTap, - this.onLinkTap, - this.textBuilder, - this.quotedMessageBuilder, - this.deletedMessageBuilder, - this.bottomRowBuilderWithDefaultWidget, - this.userAvatarBuilder, - }); - - /// {@macro reverse} - final bool reverse; - - /// {@macro isPinned} - final bool isPinned; - - /// {@macro showPinHighlight} - final bool showPinHighlight; - - /// {@macro showBottomRow} - final bool showBottomRow; - - /// {@macro message} - final Message message; - - /// {@macro showUserAvatar} - final DisplayWidget showUserAvatar; - - /// The width of the avatar. - final double avatarWidth; - - /// {@macro showReactions} - final bool showReactions; - - /// {@macro onReactionsTap} - final VoidCallback onReactionsTap; - - /// {@macro onReactionsHover} - final OnReactionsHover? onReactionsHover; - - /// {@macro messageTheme} - final StreamMessageThemeData messageTheme; - - /// {@macro onUserAvatarTap} - final void Function(User)? onUserAvatarTap; - - /// {@macro streamChatThemeData} - final StreamChatThemeData streamChatTheme; - - /// {@macro isFailedState} - final bool isFailedState; - - /// {@macro borderRadiusGeometry} - final BorderRadiusGeometry? borderRadiusGeometry; - - /// {@macro borderSide} - final BorderSide? borderSide; - - /// {@macro shape} - final ShapeBorder? shape; - - /// {@macro hasQuotedMessage} - final bool hasQuotedMessage; - - /// {@macro hasUrlAttachments} - final bool hasUrlAttachments; - - /// {@macro hasNonUrlAttachments} - final bool hasNonUrlAttachments; - - /// {@macro isOnlyEmoji} - final bool isOnlyEmoji; - - /// {@macro isGiphy} - final bool isGiphy; - - /// {@macro attachmentBuilders} - final List? attachmentBuilders; - - /// {@macro attachmentPadding} - final EdgeInsetsGeometry attachmentPadding; - - /// {@macro attachmentShape} - final ShapeBorder? attachmentShape; - - /// {@macro onAttachmentWidgetTap} - final OnAttachmentWidgetTap? onAttachmentTap; - - /// {@macro onShowMessage} - final ShowMessageCallback? onShowMessage; - - /// {@macro onReplyTap} - final void Function(Message)? onReplyTap; - - /// {@macro onThreadTap} - final void Function(Message)? onThreadTap; - - /// {@macro attachmentActionsBuilder} - final AttachmentActionsBuilder? attachmentActionsModalBuilder; - - /// {@macro textPadding} - final EdgeInsets textPadding; - - /// {@macro onQuotedMessageTap} - final OnQuotedMessageTap? onQuotedMessageTap; - - /// {@macro onMentionTap} - final void Function(User)? onMentionTap; - - /// {@macro onLinkTap} - final void Function(String)? onLinkTap; - - /// {@macro textBuilder} - final Widget Function(BuildContext, Message)? textBuilder; - - /// {@macro quotedMessageBuilder} - final Widget Function(BuildContext, Message)? quotedMessageBuilder; - - /// {@macro deletedMessageBuilder} - final Widget Function(BuildContext, Message)? deletedMessageBuilder; - - /// {@macro translateUserAvatar} - final bool translateUserAvatar; - - /// The padding to use for this widget. - final double bottomRowPadding; - - /// {@macro bottomRowBuilderWithDefaultWidget} - final BottomRowBuilderWithDefaultWidget? bottomRowBuilderWithDefaultWidget; - - /// {@macro showInChannelIndicator} - final bool showInChannel; - - /// {@macro streamChat} - final StreamChatState streamChat; - - /// {@macro showSendingIndicator} - final bool showSendingIndicator; - - /// {@macro showThreadReplyIndicator} - final bool showThreadReplyIndicator; - - /// {@macro showTimestamp} - final bool showTimeStamp; - - /// {@macro showUsername} - final bool showUsername; - - /// {@macro showEdited} - final bool showEditedLabel; - - /// {@macro messageWidget} - final StreamMessageWidget messageWidget; - - /// {@macro userAvatarBuilder} - final Widget Function(BuildContext, User)? userAvatarBuilder; - - /// {@macro reactionIndicatorBuilder} - final ReactionIndicatorBuilder reactionIndicatorBuilder; - - @override - Widget build(BuildContext context) { - final hasThreadParticipants = message.threadParticipants?.isNotEmpty == true; - - return Column( - crossAxisAlignment: reverse ? CrossAxisAlignment.end : CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Stack( - clipBehavior: Clip.none, - alignment: reverse ? AlignmentDirectional.bottomEnd : AlignmentDirectional.bottomStart, - children: [ - if (showBottomRow) - Padding( - padding: EdgeInsets.only( - left: !reverse ? bottomRowPadding : 0, - right: reverse ? bottomRowPadding : 0, - bottom: isPinned && showPinHighlight ? 6.0 : 0.0, - ), - child: _buildBottomRow(context), - ), - Padding( - padding: EdgeInsets.only( - bottom: isPinned && showPinHighlight ? 8.0 : 0.0, - ), - child: Column( - crossAxisAlignment: reverse ? CrossAxisAlignment.end : CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (isPinned && message.pinnedBy != null && showPinHighlight) - PinnedMessage( - pinnedBy: message.pinnedBy!, - currentUser: streamChat.currentUser!, - ), - Row( - spacing: 8, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - ...[ - Flexible( - child: ReactionIndicatorBubbleOverlay( - reverse: reverse, - message: message, - onTap: onReactionsTap, - visible: isMobileDevice && showReactions, - anchorOffset: const Offset(0, 36), - reactionIndicatorBuilder: reactionIndicatorBuilder, - child: Padding( - padding: switch (showReactions) { - true => const EdgeInsets.only(top: 28), - false => EdgeInsets.zero, - }, - child: _buildMessageCard(context), - ), - ), - ), - ].addConditionally( - reverse: reverse, - condition: (_) => message.user != null, - switch (showUserAvatar) { - DisplayWidget.gone => null, - DisplayWidget.hide => SizedBox(width: avatarWidth), - DisplayWidget.show => UserAvatarTransform( - onUserAvatarTap: onUserAvatarTap, - userAvatarBuilder: userAvatarBuilder, - translateUserAvatar: translateUserAvatar, - messageTheme: messageTheme, - message: message, - ), - }, - ), - ], - ), - if (isDesktopDeviceOrWeb && showReactions) ...[ - Padding( - padding: switch (showUserAvatar) { - DisplayWidget.gone => EdgeInsets.zero, - _ => EdgeInsets.only( - left: avatarWidth + 4, - right: avatarWidth + 4, - ), - }, - child: DesktopReactionsBuilder( - message: message, - messageTheme: messageTheme, - onHover: onReactionsHover, - borderSide: borderSide, - reverse: reverse, - ), - ), - ], - if (showBottomRow) - SizedBox( - height: context.textScaleFactor * (hasThreadParticipants ? 24.0 : 18.0), - ), - ], - ), - ), - if (isFailedState) - Positioned( - right: reverse ? 0 : null, - left: reverse ? null : 0, - bottom: showBottomRow ? 18 : -2, - child: Icon( - context.streamIcons.exclamationCircle1, - color: streamChatTheme.colorTheme.accentError, - ), - ), - ], - ), - ], - ); - } - - Widget _buildDeletedMessage(BuildContext context) { - if (deletedMessageBuilder case final builder?) { - return builder(context, message); - } - - return StreamDeletedMessage( - borderRadiusGeometry: borderRadiusGeometry, - borderSide: borderSide, - shape: shape, - messageTheme: messageTheme, - ); - } - - Widget _buildMessageCard(BuildContext context) { - if (message.isDeleted) { - return Container( - margin: EdgeInsetsDirectional.only( - end: reverse && isFailedState ? 12.0 : 0.0, - start: !reverse && isFailedState ? 12.0 : 0.0, - ), - child: _buildDeletedMessage(context), - ); - } - - return MessageCard( - message: message, - isFailedState: isFailedState, - showUserAvatar: showUserAvatar, - messageTheme: messageTheme, - hasQuotedMessage: hasQuotedMessage, - hasUrlAttachments: hasUrlAttachments, - hasNonUrlAttachments: hasNonUrlAttachments, - isOnlyEmoji: isOnlyEmoji, - isGiphy: isGiphy, - attachmentBuilders: attachmentBuilders, - attachmentPadding: attachmentPadding, - attachmentShape: attachmentShape, - onAttachmentTap: onAttachmentTap, - onReplyTap: onReplyTap, - onShowMessage: onShowMessage, - attachmentActionsModalBuilder: attachmentActionsModalBuilder, - textPadding: textPadding, - reverse: reverse, - onQuotedMessageTap: onQuotedMessageTap, - onMentionTap: onMentionTap, - onLinkTap: onLinkTap, - textBuilder: textBuilder, - quotedMessageBuilder: quotedMessageBuilder, - borderRadiusGeometry: borderRadiusGeometry, - borderSide: borderSide, - shape: shape, - ); - } - - Widget _buildBottomRow(BuildContext context) { - final defaultWidget = BottomRow( - onThreadTap: onThreadTap, - message: message, - reverse: reverse, - messageTheme: messageTheme, - hasUrlAttachments: hasUrlAttachments, - isOnlyEmoji: isOnlyEmoji, - isDeleted: message.isDeleted, - isGiphy: isGiphy, - showInChannel: showInChannel, - showSendingIndicator: showSendingIndicator, - showThreadReplyIndicator: showThreadReplyIndicator, - showTimeStamp: showTimeStamp, - showUsername: showUsername, - showEditedLabel: showEditedLabel, - streamChatTheme: streamChatTheme, - streamChat: streamChat, - hasNonUrlAttachments: hasNonUrlAttachments, - ); - - if (bottomRowBuilderWithDefaultWidget != null) { - return bottomRowBuilderWithDefaultWidget!( - context, - message, - defaultWidget, - ); - } - - return defaultWidget; - } -} - -extension on Iterable { - Iterable addConditionally( - T? item, { - required bool condition(T element), - bool reverse = false, - }) sync* { - for (final element in this) { - if (item != null && !reverse && condition(element)) yield item; - yield element; - if (item != null && reverse && condition(element)) yield item; - } - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content_components.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content_components.dart deleted file mode 100644 index a3a6782630..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content_components.dart +++ /dev/null @@ -1,6 +0,0 @@ -export 'bottom_row.dart'; -export 'message_card.dart'; -export 'parse_attachments.dart'; -export 'pinned_message.dart'; -export 'quoted_message.dart'; -export 'user_avatar_transform.dart'; diff --git a/packages/stream_chat_flutter/lib/src/message_widget/parse_attachments.dart b/packages/stream_chat_flutter/lib/src/message_widget/parse_attachments.dart index b0c9080cd5..5e4c386576 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/parse_attachments.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/parse_attachments.dart @@ -29,12 +29,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// ) /// ``` /// {@endtemplate} -typedef OnAttachmentWidgetTap = - FutureOr Function( - BuildContext context, - Message message, - Attachment attachment, - ); +typedef OnAttachmentWidgetTap = FutureOr Function(BuildContext context, Message message, Attachment attachment); /// {@template parseAttachments} /// Parses the attachments of a [StreamMessageWidget]. @@ -46,8 +41,8 @@ class ParseAttachments extends StatelessWidget { const ParseAttachments({ super.key, required this.message, - required this.attachmentBuilders, - required this.attachmentPadding, + this.attachmentBuilders, + this.attachmentPadding, this.attachmentShape, this.onAttachmentTap, this.onShowMessage, @@ -63,7 +58,7 @@ class ParseAttachments extends StatelessWidget { final List? attachmentBuilders; /// {@macro attachmentPadding} - final EdgeInsetsGeometry attachmentPadding; + final EdgeInsetsGeometry? attachmentPadding; /// {@macro attachmentShape} final ShapeBorder? attachmentShape; @@ -98,13 +93,16 @@ class ParseAttachments extends StatelessWidget { return _defaultAttachmentTapHandler(context, message, attachment); } + final config = StreamChatConfiguration.maybeOf(context); + final effectiveAttachmentBuilder = attachmentBuilders ?? config?.attachmentBuilders; + // Create a default attachmentBuilders list if not provided. final builders = StreamAttachmentWidgetBuilder.defaultBuilders( message: message, shape: attachmentShape, padding: attachmentPadding, onAttachmentTap: effectiveOnAttachmentTap, - customAttachmentBuilders: attachmentBuilders, + customAttachmentBuilders: effectiveAttachmentBuilder, ); final catalog = AttachmentWidgetCatalog(builders: builders); diff --git a/packages/stream_chat_flutter/lib/src/message_widget/pinned_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/pinned_message.dart deleted file mode 100644 index ae33dcb9ed..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/pinned_message.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template pinnedMessage} -/// A pinned message in a chat. -/// -/// Used in [MessageWidgetContent]. Should not be used elsewhere. -/// {@endtemplate} -class PinnedMessage extends StatelessWidget { - /// {@macro pinnedMessage} - const PinnedMessage({ - super.key, - required this.pinnedBy, - required this.currentUser, - }); - - /// The [User] who pinned this message. - final User pinnedBy; - - /// The current [User]. - final User currentUser; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - context.streamIcons.pin, - size: 16, - ), - const SizedBox( - width: 4, - ), - Text( - context.translations.pinnedByUserText( - pinnedBy: pinnedBy, - currentUser: currentUser, - ), - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - fontSize: 13, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart deleted file mode 100644 index bd9cef05bb..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template quotedMessage} -/// A quoted message in a chat. -/// -/// Used in [QuotedMessageCard]. Should not be used elsewhere. -/// {@endtemplate} -class QuotedMessage extends StatelessWidget { - /// {@macro quotedMessage} - const QuotedMessage({ - super.key, - required this.message, - required this.hasNonUrlAttachments, - this.textBuilder, - }); - - /// {@macro message} - final Message message; - - /// {@macro hasNonUrlAttachments} - final bool hasNonUrlAttachments; - - /// {@macro textBuilder} - final Widget Function(BuildContext, Message)? textBuilder; - - @override - Widget build(BuildContext context) { - final streamChat = StreamChat.of(context); - final chatThemeData = StreamChatTheme.of(context); - - final isMyMessage = message.user?.id == streamChat.currentUser?.id; - final isMyQuotedMessage = message.quotedMessage?.user?.id == streamChat.currentUser?.id; - return StreamQuotedMessageWidget( - message: message.quotedMessage!, - messageTheme: isMyMessage ? chatThemeData.otherMessageTheme : chatThemeData.ownMessageTheme, - reverse: !isMyQuotedMessage, - textBuilder: textBuilder, - padding: EdgeInsets.only( - right: 8, - left: 8, - top: 8, - bottom: hasNonUrlAttachments ? 8 : 0, - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/sending_indicator_builder.dart b/packages/stream_chat_flutter/lib/src/message_widget/sending_indicator_builder.dart deleted file mode 100644 index 919f216c45..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/sending_indicator_builder.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template sendingIndicatorWrapper} -/// Helper widget for building a [StreamSendingIndicator]. -/// -/// Used in [BottomRow]. Should not be used elsewhere. -/// {@endtemplate} -class SendingIndicatorBuilder extends StatelessWidget { - /// {@macro sendingIndicatorWrapper} - const SendingIndicatorBuilder({ - super.key, - required this.messageTheme, - required this.message, - required this.hasNonUrlAttachments, - required this.streamChat, - required this.streamChatTheme, - this.channel, - }); - - /// {@macro messageTheme} - final StreamMessageThemeData messageTheme; - - /// {@macro message} - final Message message; - - /// {@macro hasNonUrlAttachments} - final bool hasNonUrlAttachments; - - /// {@macro streamChat} - final StreamChatState streamChat; - - /// {@macro streamChatThemeData} - final StreamChatThemeData streamChatTheme; - - /// {@macro channel} - final Channel? channel; - - @override - Widget build(BuildContext context) { - final style = messageTheme.createdAtStyle; - final channel = this.channel ?? StreamChannel.of(context).channel; - - if (hasNonUrlAttachments && message.state.isOutgoing) { - final totalAttachments = message.attachments.length; - final attachmentsToUpload = message.attachments.where((it) { - return !it.uploadState.isSuccess; - }); - - if (attachmentsToUpload.isNotEmpty) { - return Text( - context.translations.attachmentsUploadProgressText( - remaining: attachmentsToUpload.length, - total: totalAttachments, - ), - style: style, - ); - } - } - - return BetterStreamBuilder>( - stream: channel.state?.readStream, - initialData: channel.state?.read, - builder: (context, data) { - final readList = data.readsOf(message: message); - final isMessageRead = readList.isNotEmpty; - - final deliveriesList = data.deliveriesOf(message: message); - final isMessageDelivered = deliveriesList.isNotEmpty; - - return StreamSendingIndicator( - message: message, - isMessageRead: isMessageRead, - isMessageDelivered: isMessageDelivered, - size: 16, - ); - }, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/text_bubble.dart b/packages/stream_chat_flutter/lib/src/message_widget/text_bubble.dart deleted file mode 100644 index 4f3ad434ad..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/text_bubble.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart' as core; - -/// {@template textBubble} -/// The bubble around a [StreamMessageText]. -/// -/// Used in [MessageCard]. Should not be used elsewhere. -/// {@endtemplate} -class TextBubble extends StatelessWidget { - /// {@macro textBubble} - const TextBubble({ - super.key, - required this.message, - required this.isOnlyEmoji, - required this.textPadding, - required this.messageStyle, - required this.hasUrlAttachments, - required this.hasQuotedMessage, - this.textBuilder, - this.onLinkTap, - this.onMentionTap, - }); - - /// {@macro message} - final Message message; - - /// {@macro isOnlyEmoji} - final bool isOnlyEmoji; - - /// {@macro textPadding} - final EdgeInsets textPadding; - - /// {@macro textBuilder} - final Widget Function(BuildContext, Message)? textBuilder; - - /// {@macro onLinkTap} - final void Function(String)? onLinkTap; - - /// {@macro onMentionTap} - final void Function(User)? onMentionTap; - - /// TODO: merge with messageTheme - final core.StreamMessageStyle messageStyle; - - /// {@macro hasUrlAttachments} - final bool hasUrlAttachments; - - /// {@macro hasQuotedMessage} - final bool hasQuotedMessage; - - @override - Widget build(BuildContext context) { - if (message.text?.trim().isEmpty ?? true) return const Empty(); - return DefaultTextStyle( - style: context.streamTextTheme.bodyDefault.copyWith( - color: messageStyle.textColor, - fontSize: isOnlyEmoji ? 42 : null, - ), - child: Padding( - padding: isOnlyEmoji ? EdgeInsets.zero : textPadding, - child: textBuilder != null - ? textBuilder!(context, message) - : StreamMessageText( - onLinkTap: onLinkTap, - message: message, - onMentionTap: onMentionTap, - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/thread_painter.dart b/packages/stream_chat_flutter/lib/src/message_widget/thread_painter.dart deleted file mode 100644 index 09bb63c93f..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/thread_painter.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template threadReplyPainter} -/// A custom painter used to render thread replies. -/// -/// Used in [BottomRow]. -/// {@endtemplate} -class ThreadReplyPainter extends CustomPainter { - /// {@macro threadReplyPainter} - const ThreadReplyPainter({ - this.context, - required this.color, - this.reverse = false, - }); - - /// The color to paint the thread reply with. - final Color? color; - - /// The [BuildContext] to use to retrieve the [StreamChatTheme]. - final BuildContext? context; - - /// {@macro reverse} - final bool reverse; - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color ?? StreamChatTheme.of(context!).colorTheme.disabled - ..style = PaintingStyle.stroke - ..strokeWidth = 1 - ..strokeCap = StrokeCap.round; - - final path = Path() - ..moveTo(reverse ? size.width : 0, 0) - ..quadraticBezierTo( - reverse ? size.width : 0, - size.height * 0.38, - reverse ? size.width : 0, - size.height * 0.5, - ) - ..quadraticBezierTo( - reverse ? size.width : 0, - size.height, - reverse ? 0 : size.width, - size.height, - ); - canvas.drawPath(path, paint); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/thread_participants.dart b/packages/stream_chat_flutter/lib/src/message_widget/thread_participants.dart deleted file mode 100644 index 488224c399..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/thread_participants.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template threadParticipants} -/// Shows the users participating in a thread. -/// -/// Used in [BottomRow]. -/// {@endtemplate} -class ThreadParticipants extends StatelessWidget { - /// {@macro threadParticipants} - const ThreadParticipants({ - super.key, - required this.threadParticipants, - }); - - /// The users participating in the thread. - final Iterable threadParticipants; - - @override - Widget build(BuildContext context) { - // TODO(redesign): Old design used 14px diameter avatars, but .xs is 20px. - return StreamUserAvatarStack( - max: 3, - size: .xs, - users: threadParticipants, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/user_avatar_transform.dart b/packages/stream_chat_flutter/lib/src/message_widget/user_avatar_transform.dart deleted file mode 100644 index 7893572f9a..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/user_avatar_transform.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template userAvatarTransform} -/// Transforms a [StreamUserAvatar] according to the specified translation. -/// -/// Used in [MessageWidgetContent]. -/// {@endtemplate} -class UserAvatarTransform extends StatelessWidget { - /// {@macro userAvatarTransform} - const UserAvatarTransform({ - super.key, - required this.translateUserAvatar, - required this.messageTheme, - required this.message, - this.userAvatarBuilder, - this.onUserAvatarTap, - }); - - /// {@macro translateUserAvatar} - final bool translateUserAvatar; - - /// {@macro messageTheme} - final StreamMessageThemeData messageTheme; - - /// {@macro userAvatarBuilder} - final Widget Function(BuildContext, User)? userAvatarBuilder; - - /// {@macro message} - final Message message; - - /// {@macro onUserAvatarTap} - final void Function(User)? onUserAvatarTap; - - @override - Widget build(BuildContext context) { - return Transform.translate( - offset: Offset( - 0, - translateUserAvatar ? (messageTheme.avatarTheme?.constraints.maxHeight ?? 40) / 2 : 0, - ), - child: switch (userAvatarBuilder) { - final builder? => builder(context, message.user!), - _ => GestureDetector( - onTap: switch (onUserAvatarTap) { - final onTap? => () => onTap(message.user!), - _ => null, - }, - child: StreamUserAvatar( - size: .md, - user: message.user!, - showOnlineIndicator: false, - ), - ), - }, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/username.dart b/packages/stream_chat_flutter/lib/src/message_widget/username.dart deleted file mode 100644 index 32f412ed65..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/username.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template username} -/// Displays the username of a particular message's sender. -/// {@endtemplate} -class Username extends StatelessWidget { - /// {@macro username} - const Username({ - super.key, - required this.message, - required this.textStyle, - }); - - /// {@macro message} - final Message message; - - /// {@macro messageTheme} - final TextStyle textStyle; - - @override - Widget build(BuildContext context) { - return Text( - message.user?.name ?? '', - maxLines: 1, - key: key, - style: textStyle, - overflow: TextOverflow.ellipsis, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator.dart b/packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator.dart deleted file mode 100644 index fa442cf2ab..0000000000 --- a/packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template reactionIndicatorBuilder} -/// Signature for a function that builds a custom reaction indicator widget. -/// -/// This allows users to customize how reactions are displayed on messages, -/// including showing reaction counts alongside emojis. -/// -/// Parameters: -/// - [context]: The build context. -/// - [message]: The message containing the reactions to display. -/// - [onTap]: An optional callback triggered when the reaction indicator -/// is tapped. -/// {@endtemplate} -typedef ReactionIndicatorBuilder = Widget Function(BuildContext context, Message message, VoidCallback? onTap); - -/// {@template streamReactionIndicator} -/// A widget that displays a horizontal list of reaction icons that users have -/// reacted with on a message. -/// -/// This widget is typically used to show the reactions on a message in a -/// compact way, allowing users to see which reactions have been added -/// to a message without opening a full user reactions view. -/// {@endtemplate} -class StreamReactionIndicator extends StatelessWidget { - /// {@macro streamReactionIndicator} - const StreamReactionIndicator({ - super.key, - this.onTap, - required this.message, - this.backgroundColor, - this.padding, - this.borderRadius, - this.reactionSorting, - }); - - /// Creates a [StreamReactionIndicator] using the default configuration. - /// - /// This is the recommended way to create a reaction indicator - /// as it ensures that the icons are consistent with the rest of the app. - factory StreamReactionIndicator.builder( - BuildContext _, - Message message, - VoidCallback? onTap, - ) { - return StreamReactionIndicator(onTap: onTap, message: message); - } - - /// Callback triggered when the reaction indicator is tapped. - final VoidCallback? onTap; - - /// Message to attach the reaction to. - final Message message; - - /// Background color for the reaction indicator. - final Color? backgroundColor; - - /// Padding around the reaction indicator. - /// - /// Defaults to `EdgeInsets.all(8)`. - final EdgeInsetsGeometry? padding; - - /// Border radius for the reaction indicator. - /// - /// Defaults to a circular border with a radius of 26. - final BorderRadiusGeometry? borderRadius; - - /// Sorting strategy for the reaction. - /// - /// Defaults to sorting by the first reaction at. - final Comparator? reactionSorting; - - @override - Widget build(BuildContext context) { - final radius = context.streamRadius; - final spacing = context.streamSpacing; - final colorScheme = context.streamColorScheme; - - final effectivePadding = padding ?? .symmetric(horizontal: spacing.xs, vertical: spacing.xxs); - final effectiveBorderRadius = borderRadius ?? BorderRadius.all(radius.max); - final effectiveBackgroundColor = backgroundColor ?? colorScheme.backgroundElevation3; - - final side = BorderSide(color: colorScheme.borderDefault); - final shape = RoundedSuperellipseBorder(borderRadius: effectiveBorderRadius, side: side); - - final config = StreamChatConfiguration.of(context); - final resolver = config.reactionIconResolver; - - final reactionGroups = message.reactionGroups?.entries; - final effectiveReactionSorting = reactionSorting ?? ReactionSorting.byFirstReactionAt; - final sortedReactionGroups = reactionGroups?.sortedByCompare((it) => it.value, effectiveReactionSorting); - - final indicatorIcons = sortedReactionGroups?.map( - (group) => StreamEmoji( - size: StreamEmojiSize.sm, - emoji: resolver.resolve(context, group.key), - ), - ); - - final indicatorContent = Row( - mainAxisSize: .min, - spacing: spacing.xxs, - children: [...?indicatorIcons], - ); - - return Material( - shape: shape, - elevation: 3, - clipBehavior: .antiAlias, - color: effectiveBackgroundColor, - child: InkWell( - onTap: onTap, - child: SingleChildScrollView( - padding: effectivePadding, - scrollDirection: .horizontal, - child: indicatorContent, - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator_bubble_overlay.dart b/packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator_bubble_overlay.dart deleted file mode 100644 index 7ef2202d63..0000000000 --- a/packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator_bubble_overlay.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/reactions/indicator/reaction_indicator.dart'; -import 'package:stream_chat_flutter/src/reactions/reaction_bubble_overlay.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; - -/// {@template reactionIndicatorBubbleOverlay} -/// A widget that displays a reaction indicator bubble overlay attached to a -/// [child] widget. Typically used to show the reactions for a [Message]. -/// -/// It positions the reaction indicator relative to the provided [child] widget, -/// using the given [anchorOffset] and [childSizeDelta] for fine-tuned placement -/// {@endtemplate} -class ReactionIndicatorBubbleOverlay extends StatelessWidget { - /// {@macro reactionIndicatorBubbleOverlay} - const ReactionIndicatorBubbleOverlay({ - super.key, - this.onTap, - required this.message, - required this.child, - this.visible = true, - this.reverse = false, - this.anchorOffset = Offset.zero, - this.reactionIndicatorBuilder = StreamReactionIndicator.builder, - }); - - /// Whether the overlay should be visible. - final bool visible; - - /// Whether to reverse the alignment of the overlay. - final bool reverse; - - /// The widget to which the overlay is anchored. - final Widget child; - - /// The message to display reactions for. - final Message message; - - /// Callback triggered when the reaction indicator is tapped. - final VoidCallback? onTap; - - /// The offset to apply to the anchor position. - final Offset anchorOffset; - - /// Builder for the reaction indicator widget. - final ReactionIndicatorBuilder reactionIndicatorBuilder; - - @override - Widget build(BuildContext context) { - return ReactionBubbleOverlay( - visible: visible, - anchor: ReactionBubbleAnchor( - offset: anchorOffset, - follower: AlignmentDirectional.bottomCenter, - target: AlignmentDirectional(reverse ? -1 : 1, -1), - ), - reaction: reactionIndicatorBuilder.call(context, message, onTap), - child: child, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker.dart b/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker.dart index a7d1b8f361..a2fa8911ba 100644 --- a/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker.dart +++ b/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_flutter/src/components/stream_chat_component_builders.dart'; +import 'package:stream_chat_flutter/src/stream_chat_configuration.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template onReactionPicked} @@ -35,27 +37,37 @@ typedef ReactionPickerBuilder = /// The reaction picker can be configured with custom reaction types, padding, /// border radius, and can be made scrollable or static depending on the /// specific needs. +/// +/// See also: +/// +/// * [StreamReactionPickerProps], which configures this widget. +/// * [DefaultStreamReactionPicker], the default implementation. /// {@endtemplate} class StreamReactionPicker extends StatelessWidget { /// {@macro streamReactionPicker} - const StreamReactionPicker({ + StreamReactionPicker({ super.key, - this.onReactionPicked, - required this.message, - this.backgroundColor, - this.padding, - this.borderRadius, - }); - - /// Creates a [StreamReactionPicker] using the default reaction types - /// provided by the [StreamChatConfiguration]. - /// - /// This is the recommended way to create a reaction picker - /// as it ensures that the icons are consistent with the rest of the app. + required Message message, + OnReactionPicked? onReactionPicked, + Color? backgroundColor, + EdgeInsetsGeometry? padding, + BorderRadiusGeometry? borderRadius, + }) : props = StreamReactionPickerProps( + message: message, + onReactionPicked: onReactionPicked, + backgroundColor: backgroundColor, + padding: padding, + borderRadius: borderRadius, + ); + + /// The properties that configure this reaction picker. + final StreamReactionPickerProps props; + + /// Creates a [StreamReactionPicker] with platform-appropriate defaults. /// - /// The [onReactionPicked] callback is optional and can be used to handle - /// the reaction selection. - factory StreamReactionPicker.builder( + /// On iOS/Android the picker uses rounded corners; on desktop/web the + /// border radius is set to zero. + static Widget builder( BuildContext context, Message message, OnReactionPicked? onReactionPicked, @@ -74,10 +86,35 @@ class StreamReactionPicker extends StatelessWidget { }; } - /// Message to attach the reaction to. + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamReactionPicker(props: props); + } +} + +/// Properties for configuring a [StreamReactionPicker]. +/// +/// See also: +/// +/// * [StreamReactionPicker], which uses these properties. +/// * [DefaultStreamReactionPicker], the default implementation. +@immutable +class StreamReactionPickerProps { + /// Creates properties for a reaction picker. + const StreamReactionPickerProps({ + required this.message, + this.onReactionPicked, + this.backgroundColor, + this.padding, + this.borderRadius, + }); + + /// The message to attach the reaction to. final Message message; - /// {@macro onReactionPressed} + /// {@macro onReactionPicked} final OnReactionPicked? onReactionPicked; /// Background color for the reaction picker. @@ -85,13 +122,31 @@ class StreamReactionPicker extends StatelessWidget { /// Padding around the reaction picker. /// - /// Defaults to `EdgeInsets.all(4)`. + /// When null, defaults to `EdgeInsetsDirectional.only(start: spacing.xxs)`. final EdgeInsetsGeometry? padding; /// Border radius for the reaction picker. /// - /// Defaults to a circular border with a radius of 24. + /// When null, defaults to a circular border with radius `xxxxl`. final BorderRadiusGeometry? borderRadius; +} + +/// The default implementation of [StreamReactionPicker]. +/// +/// Resolves [StreamReactionPickerProps] into a horizontal row of reaction +/// emoji buttons plus an "add reaction" button that opens the emoji picker +/// sheet. +/// +/// See also: +/// +/// * [StreamReactionPicker], the public API widget. +/// * [StreamReactionPickerProps], which configures this widget. +class DefaultStreamReactionPicker extends StatelessWidget { + /// Creates a default reaction picker with the given [props]. + const DefaultStreamReactionPicker({super.key, required this.props}); + + /// The properties that configure this widget. + final StreamReactionPickerProps props; @override Widget build(BuildContext context) { @@ -100,9 +155,9 @@ class StreamReactionPicker extends StatelessWidget { final spacing = context.streamSpacing; final colorScheme = context.streamColorScheme; - final effectivePadding = padding ?? EdgeInsetsDirectional.only(start: spacing.xxs); - final effectiveBorderRadius = borderRadius ?? BorderRadius.all(radius.xxxxl); - final effectiveBackgroundColor = backgroundColor ?? colorScheme.backgroundElevation2; + final effectivePadding = props.padding ?? EdgeInsetsDirectional.only(start: spacing.xxs); + final effectiveBorderRadius = props.borderRadius ?? BorderRadius.all(radius.xxxxl); + final effectiveBackgroundColor = props.backgroundColor ?? colorScheme.backgroundElevation2; final side = BorderSide(color: colorScheme.borderDefault); final shape = RoundedSuperellipseBorder(borderRadius: effectiveBorderRadius, side: side); @@ -111,6 +166,9 @@ class StreamReactionPicker extends StatelessWidget { final resolver = config.reactionIconResolver; final reactionTypes = resolver.defaultReactions; + final message = props.message; + final onReactionPicked = props.onReactionPicked; + final ownReactions = [...?message.ownReactions]; final ownReactionsMap = {for (final it in ownReactions) it.type: it}; diff --git a/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker_bubble_overlay.dart b/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker_bubble_overlay.dart deleted file mode 100644 index 5b7da48e6c..0000000000 --- a/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker_bubble_overlay.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/reactions/picker/reaction_picker.dart'; -import 'package:stream_chat_flutter/src/reactions/reaction_bubble_overlay.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; - -/// {@template reactionPickerBubbleOverlay} -/// A widget that displays a reaction picker bubble overlay attached to a -/// [child] widget. Typically used with the [MessageWidget] as the child. -/// -/// It positions the reaction picker relative to the provided [child] widget, -/// using the given [anchorOffset] and [childSizeDelta] for fine-tuned placement -/// {@endtemplate} -class ReactionPickerBubbleOverlay extends StatelessWidget { - /// {@macro reactionPickerBubbleOverlay} - const ReactionPickerBubbleOverlay({ - super.key, - required this.message, - required this.child, - this.onReactionPicked, - this.visible = true, - this.reverse = false, - this.anchorOffset = Offset.zero, - this.reactionPickerBuilder = StreamReactionPicker.builder, - }); - - /// Whether the overlay should be visible. - final bool visible; - - /// Whether to reverse the alignment of the overlay. - final bool reverse; - - /// The widget to which the overlay is anchored. - final Widget child; - - /// The message to attach the reaction to. - final Message message; - - /// Callback triggered when a reaction is picked. - final OnReactionPicked? onReactionPicked; - - /// Builder for the reaction picker widget. - final ReactionPickerBuilder reactionPickerBuilder; - - /// The offset to apply to the anchor position. - final Offset anchorOffset; - - @override - Widget build(BuildContext context) { - return ReactionBubbleOverlay( - visible: visible, - anchor: ReactionBubbleAnchor( - offset: anchorOffset, - follower: AlignmentDirectional(reverse ? 1 : -1, 1), - target: AlignmentDirectional(reverse ? 1 : -1, -1), - ), - reaction: reactionPickerBuilder.call(context, message, onReactionPicked), - child: child, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/reactions/reaction_bubble_overlay.dart b/packages/stream_chat_flutter/lib/src/reactions/reaction_bubble_overlay.dart deleted file mode 100644 index 2a5087ecf2..0000000000 --- a/packages/stream_chat_flutter/lib/src/reactions/reaction_bubble_overlay.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_portal/flutter_portal.dart'; - -/// Defines the anchor settings for positioning a ReactionBubble relative to a -/// target widget. -class ReactionBubbleAnchor { - /// Creates an anchor with custom alignment and offset. - const ReactionBubbleAnchor({ - this.offset = Offset.zero, - required this.target, - required this.follower, - this.shiftToWithinBound = const AxisFlag(x: true), - }); - - /// Creates an anchor that positions the bubble at the top-end of the - /// target widget. - const ReactionBubbleAnchor.topEnd({ - this.offset = Offset.zero, - this.shiftToWithinBound = const AxisFlag(x: true), - }) : target = AlignmentDirectional.topEnd, - follower = AlignmentDirectional.bottomCenter; - - /// Creates an anchor that positions the bubble at the top-start of the - /// target widget. - const ReactionBubbleAnchor.topStart({ - this.offset = Offset.zero, - this.shiftToWithinBound = const AxisFlag(x: true), - }) : target = AlignmentDirectional.topStart, - follower = AlignmentDirectional.bottomCenter; - - /// Additional offset applied to the bubble position. - final Offset offset; - - /// Target alignment relative to the target widget. - final AlignmentDirectional target; - - /// Alignment of the bubble follower relative to the target alignment. - final AlignmentDirectional follower; - - /// Whether to shift the bubble within the visible screen bounds along each - /// axis if it exceeds the screen size. - final AxisFlag shiftToWithinBound; -} - -/// An overlay widget that displays a reaction bubble near a child widget. -class ReactionBubbleOverlay extends StatelessWidget { - /// Creates a new instance of [ReactionBubbleOverlay]. - const ReactionBubbleOverlay({ - super.key, - this.visible = true, - required this.child, - required this.reaction, - this.anchor = const ReactionBubbleAnchor.topEnd(), - }); - - /// The target child widget to anchor the reaction bubble to. - final Widget child; - - /// The reaction widget to display inside the bubble. - final Widget reaction; - - /// Whether the reaction bubble is visible. - final bool visible; - - /// The anchor configuration to control bubble positioning. - final ReactionBubbleAnchor anchor; - - @override - Widget build(BuildContext context) { - // If the overlay should not be visible, return the child without any overlay. - if (!visible) return child; - - final alignment = anchor; - final direction = Directionality.maybeOf(context); - final targetAlignment = alignment.target.resolve(direction); - final followerAlignment = alignment.follower.resolve(direction); - - return PortalTarget( - anchor: Aligned( - target: targetAlignment, - follower: followerAlignment, - offset: anchor.offset, - shiftToWithinBound: anchor.shiftToWithinBound, - ), - portalFollower: reaction, - child: child, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/stream_chat.dart b/packages/stream_chat_flutter/lib/src/stream_chat.dart index 158980f0ad..280f2a7bc3 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat.dart @@ -36,6 +36,7 @@ class StreamChat extends StatefulWidget { required this.child, this.streamChatThemeData, this.streamChatConfigData, + this.componentBuilders, this.onBackgroundEventReceived, this.backgroundKeepAlive = const Duration(minutes: 1), this.connectivityStream, @@ -53,6 +54,36 @@ class StreamChat extends StatefulWidget { /// Non-theme related UI configuration options. final StreamChatConfigurationData? streamChatConfigData; + /// Custom component builders for overriding default UI components. + /// + /// When provided, a [StreamComponentFactory] is inserted into the widget + /// tree below the theme and above [StreamChatCore], allowing all descendant + /// widgets to resolve custom builders. + /// + /// {@tool snippet} + /// + /// Override the default message widget with a custom builder: + /// + /// ```dart + /// StreamChat( + /// client: client, + /// componentBuilders: StreamComponentBuilders( + /// extensions: streamChatComponentBuilders( + /// messageWidget: (context, props) { + /// return DefaultStreamMessage( + /// props: props.copyWith( + /// actionsBuilder: myActionsBuilder, + /// ), + /// ); + /// }, + /// ), + /// ), + /// child: MyApp(), + /// ) + /// ``` + /// {@end-tool} + final StreamComponentBuilders? componentBuilders; + /// The amount of time that will pass before disconnecting the client /// in the background final Duration backgroundKeepAlive; @@ -154,37 +185,37 @@ class StreamChatState extends State { @override Widget build(BuildContext context) { final theme = _getTheme(context, widget.streamChatThemeData); - return StreamChatConfiguration( - data: streamChatConfigData, - child: StreamChatTheme( - data: theme, - child: Builder( - builder: (context) { - final materialTheme = Theme.of(context); - final streamTheme = StreamChatTheme.of(context); - return Theme( - data: materialTheme.copyWith( - primaryIconTheme: streamTheme.primaryIconTheme, - colorScheme: materialTheme.colorScheme.copyWith( - secondary: streamTheme.colorTheme.accentPrimary, - ), - ), - child: StreamChatCore( - client: client, - onBackgroundEventReceived: widget.onBackgroundEventReceived, - backgroundKeepAlive: widget.backgroundKeepAlive, - connectivityStream: widget.connectivityStream, - child: Builder( - builder: (context) { - return widget.child ?? const Empty(); - }, - ), + + Widget child = StreamChatTheme( + data: theme, + child: Builder( + builder: (context) { + final materialTheme = Theme.of(context); + final streamTheme = StreamChatTheme.of(context); + return Theme( + data: materialTheme.copyWith( + primaryIconTheme: streamTheme.primaryIconTheme, + colorScheme: materialTheme.colorScheme.copyWith( + secondary: streamTheme.colorTheme.accentPrimary, ), - ); - }, - ), + ), + child: StreamChatCore( + client: client, + onBackgroundEventReceived: widget.onBackgroundEventReceived, + backgroundKeepAlive: widget.backgroundKeepAlive, + connectivityStream: widget.connectivityStream, + child: widget.child ?? const Empty(), + ), + ); + }, ), ); + + if (widget.componentBuilders case final builders?) { + child = StreamComponentFactory(builders: builders, child: child); + } + + return StreamChatConfiguration(data: streamChatConfigData, child: child); } StreamChatThemeData _getTheme( 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 dc015c83c3..bc9c5207c5 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart @@ -166,6 +166,9 @@ class StreamChatConfigurationData { bool draftMessagesEnabled = false, MessagePreviewFormatter? messagePreviewFormatter, StreamImageCDN imageCDN = const StreamImageCDN(), + List? attachmentBuilders, + StreamReactionsType? reactionType, + StreamReactionsPosition? reactionPosition, }) { return StreamChatConfigurationData._( loadingIndicator: loadingIndicator, @@ -176,6 +179,9 @@ class StreamChatConfigurationData { draftMessagesEnabled: draftMessagesEnabled, messagePreviewFormatter: messagePreviewFormatter ?? MessagePreviewFormatter(), imageCDN: imageCDN, + attachmentBuilders: attachmentBuilders, + reactionType: reactionType, + reactionPosition: reactionPosition, ); } @@ -188,6 +194,9 @@ class StreamChatConfigurationData { required this.draftMessagesEnabled, required this.messagePreviewFormatter, required this.imageCDN, + required this.attachmentBuilders, + this.reactionType, + this.reactionPosition, }); /// Copies the configuration options from one [StreamChatConfigurationData] to @@ -201,6 +210,9 @@ class StreamChatConfigurationData { bool? draftMessagesEnabled, MessagePreviewFormatter? messagePreviewFormatter, StreamImageCDN? imageCDN, + List? attachmentBuilders, + StreamReactionsType? reactionType, + StreamReactionsPosition? reactionPosition, }) { return StreamChatConfigurationData( reactionIconResolver: reactionIconResolver ?? this.reactionIconResolver, @@ -211,6 +223,9 @@ class StreamChatConfigurationData { draftMessagesEnabled: draftMessagesEnabled ?? this.draftMessagesEnabled, messagePreviewFormatter: messagePreviewFormatter ?? this.messagePreviewFormatter, imageCDN: imageCDN ?? this.imageCDN, + attachmentBuilders: attachmentBuilders ?? this.attachmentBuilders, + reactionType: reactionType ?? this.reactionType, + reactionPosition: reactionPosition ?? this.reactionPosition, ); } @@ -249,6 +264,26 @@ class StreamChatConfigurationData { /// Extend [StreamImageCDN] to customize behavior for a custom CDN. final StreamImageCDN imageCDN; + /// Custom attachment builders for rendering attachment widgets in messages. + /// + /// When non-null, these builders are prepended to the default builders + /// based on the [Attachment.type], allowing custom attachment types to be + /// rendered globally across all message widgets. + final List? attachmentBuilders; + + /// The visual type of the reactions display used across all message widgets. + /// + /// When null, the widget resolves its own default + /// ([StreamReactionsType.segmented]). + final StreamReactionsType? reactionType; + + /// Where reactions appear relative to the message bubble across all + /// message widgets. + /// + /// When null, the widget resolves its own default + /// ([StreamReactionsPosition.header]). + final StreamReactionsPosition? reactionPosition; + static Widget _defaultUserImage( BuildContext context, User user, diff --git a/packages/stream_chat_flutter/lib/src/utils/date_formatter.dart b/packages/stream_chat_flutter/lib/src/utils/date_formatter.dart index 7cad9fb564..0b0f22c2c4 100644 --- a/packages/stream_chat_flutter/lib/src/utils/date_formatter.dart +++ b/packages/stream_chat_flutter/lib/src/utils/date_formatter.dart @@ -3,11 +3,7 @@ import 'package:jiffy/jiffy.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; /// Represents a function type that formats a date. -typedef DateFormatter = - String Function( - BuildContext context, - DateTime date, - ); +typedef DateFormatter = String Function(BuildContext context, DateTime date); /// Formats the given [date] as a String. String formatDate(BuildContext context, DateTime date) { diff --git a/packages/stream_chat_flutter/lib/src/utils/typedefs.dart b/packages/stream_chat_flutter/lib/src/utils/typedefs.dart index ccc1fe6107..99c2b4a37f 100644 --- a/packages/stream_chat_flutter/lib/src/utils/typedefs.dart +++ b/packages/stream_chat_flutter/lib/src/utils/typedefs.dart @@ -256,32 +256,8 @@ typedef MessageSearchItemBuilder = GetMessageResponse, ); -/// {@template messageBuilder} -/// A widget builder for creating custom message UI. -/// -/// [defaultMessageWidget] is the default [StreamMessageWidget] configuration. -/// Use [defaultMessageWidget.copyWith] to customize it. -/// {@endtemplate} -typedef MessageBuilder = - Widget Function( - BuildContext, - MessageDetails, - List, - StreamMessageWidget defaultMessageWidget, - ); - -/// {@template parentMessageBuilder} -/// A widget builder for creating custom parent message UI. -/// -/// [defaultMessageWidget] is the default [StreamMessageWidget] configuration. -/// Use [defaultMessageWidget.copyWith] to customize it. -/// {@endtemplate} -typedef ParentMessageBuilder = - Widget Function( - BuildContext, - Message?, - StreamMessageWidget defaultMessageWidget, - ); +// Legacy MessageBuilder and ParentMessageBuilder typedefs removed. +// Use StreamMessageWidgetBuilder from message_list_view.dart instead. /// {@template systemMessageBuilder} /// A widget builder for creating custom system messages. diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index c2c86e280e..5402e9d65f 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -24,6 +24,11 @@ export 'package:stream_core_flutter/stream_core_flutter.dart' StreamEmojiSize, StreamEmojiData, StreamEmojiPickerSheet, + StreamMessageAlignment, + StreamMessagePlacement, + StreamMessageStackPosition, + StreamReactionsPosition, + StreamReactionsType, streamSupportedEmojis; export 'src/ai_assistant/ai_typing_indicator_view.dart'; @@ -101,15 +106,10 @@ export 'src/message_list_view/message_list_view.dart'; 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/message_reactions_modal.dart'; export 'src/message_modal/moderated_message_actions_modal.dart'; -export 'src/message_widget/deleted_message.dart'; -export 'src/message_widget/message_text.dart'; export 'src/message_widget/message_widget.dart'; -export 'src/message_widget/message_widget_content_components.dart'; export 'src/message_widget/moderated_message.dart'; export 'src/message_widget/system_message.dart'; -export 'src/message_widget/text_bubble.dart'; export 'src/misc/adaptive_dialog_action.dart'; export 'src/misc/animated_circle_border_painter.dart'; export 'src/misc/back_button.dart'; @@ -133,7 +133,6 @@ export 'src/poll/stream_poll_options_dialog.dart'; export 'src/poll/stream_poll_results_dialog.dart'; export 'src/poll/stream_poll_text_field.dart'; export 'src/reactions/detail/reaction_detail_sheet.dart'; -export 'src/reactions/indicator/reaction_indicator.dart'; export 'src/reactions/picker/reaction_picker.dart'; export 'src/reactions/user_reactions.dart'; export 'src/scroll_view/channel_scroll_view/stream_channel_grid_tile.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index ab86210cd0..b644c780b4 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -60,10 +60,7 @@ dependencies: shimmer: ^3.0.0 stream_chat_flutter_core: ^10.0.0-beta.12 stream_core_flutter: - git: - url: https://github.com/GetStream/stream-core-flutter.git - ref: 213dfb64b1d0c22a668a4a0924503703ff9a33e9 - path: packages/stream_core_flutter + path: /Users/xsahil03x/StudioProjects/stream-core-flutter/packages/stream_core_flutter svg_icon_widget: ^0.0.1 synchronized: ^3.1.0+1 theme_extensions_builder_annotation: ^7.1.0 diff --git a/packages/stream_chat_flutter/test/src/message_list_view/bottom_row_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/bottom_row_test.dart deleted file mode 100644 index 93cd4b110f..0000000000 --- a/packages/stream_chat_flutter/test/src/message_list_view/bottom_row_test.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../mocks.dart'; - -void main() { - late Channel channel; - late ChannelClientState channelClientState; - - setUp(() { - channel = MockChannel(); - when(() => channel.on(any(), any(), any(), any())).thenAnswer((_) => const Stream.empty()); - channelClientState = MockChannelState(); - when(() => channel.state).thenReturn(channelClientState); - when(() => channelClientState.messages).thenReturn([ - Message( - id: 'parentId', - ), - ]); - }); - - setUpAll(() { - registerFallbackValue(Message()); - }); - - testWidgets('BottomRow', (tester) async { - final theme = StreamChatThemeData.light(); - final onThreadTap = MockValueChanged(); - - await tester.pumpWidget( - StreamChatTheme( - data: theme, - child: MaterialApp( - home: Scaffold( - body: Center( - child: StreamChannel( - channel: channel, - child: BottomRow( - message: Message( - parentId: 'parentId', - ), - isDeleted: false, - showThreadReplyIndicator: false, - showUsername: false, - showInChannel: true, - showTimeStamp: false, - showEditedLabel: false, - reverse: false, - showSendingIndicator: false, - hasUrlAttachments: false, - isGiphy: false, - isOnlyEmoji: false, - messageTheme: theme.otherMessageTheme, - streamChatTheme: theme, - hasNonUrlAttachments: false, - streamChat: StreamChatState(), - onThreadTap: onThreadTap, - ), - ), - ), - ), - ), - ), - ); - - // wait for the initial state to be rendered. - await tester.pump(Duration.zero); - - await tester.tap(find.byType(GestureDetector)); - await tester.pumpAndSettle(); - - verify(() => onThreadTap.call(any())); - }); -} diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_dark.png deleted file mode 100644 index ed7e11f4e68b1f28e889d399037384bb8fe97d97..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8707 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 diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_light.png deleted file mode 100644 index 4f907ddec5b236625847de4cfd23371855e53838..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10915 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 Ds+TP+ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_light.png deleted file mode 100644 index ee59757e4e1e6b13d12d409c7bb95b62ac395f17..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10801 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 diff --git a/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart index 6de1cc9a08..1128bdba2f 100644 --- a/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart +++ b/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart @@ -5,7 +5,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' + show StreamIconData; void main() { final message = Message( @@ -46,6 +47,7 @@ void main() { message: message, messageActions: messageActions, messageWidget: const Text('Message Widget'), + alignment: AlignmentDirectional.centerStart, ), ), ); @@ -66,6 +68,7 @@ void main() { message: message, messageActions: messageActions, messageWidget: const Text('Message Widget'), + alignment: AlignmentDirectional.centerStart, showReactionPicker: true, ), ), @@ -102,6 +105,7 @@ void main() { message: message, messageActions: messageActions, messageWidget: const Text('Message Widget'), + alignment: AlignmentDirectional.centerStart, showReactionPicker: true, ), ); @@ -183,6 +187,7 @@ void main() { message: message, messageActions: messageActions, messageWidget: buildMessageWidget(), + alignment: AlignmentDirectional.centerStart, ), ), ); @@ -197,6 +202,7 @@ void main() { message: message, messageActions: messageActions, messageWidget: buildMessageWidget(), + alignment: AlignmentDirectional.centerStart, showReactionPicker: true, ), ), @@ -212,7 +218,7 @@ void main() { message: message, messageActions: messageActions, messageWidget: buildMessageWidget(reverse: true), - reverse: true, + alignment: AlignmentDirectional.centerEnd, ), ), ); @@ -227,8 +233,8 @@ void main() { message: message, messageActions: messageActions, messageWidget: buildMessageWidget(reverse: true), + alignment: AlignmentDirectional.centerEnd, showReactionPicker: true, - reverse: true, ), ), ); diff --git a/packages/stream_chat_flutter/test/src/message_modal/message_reactions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_modal/message_reactions_modal_test.dart deleted file mode 100644 index aa2db5ea2d..0000000000 --- a/packages/stream_chat_flutter/test/src/message_modal/message_reactions_modal_test.dart +++ /dev/null @@ -1,327 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_portal/flutter_portal.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../mocks.dart'; - -void main() { - final message = Message( - id: 'test-message', - text: 'This is a test message', - createdAt: DateTime.now(), - user: User(id: 'test-user', name: 'Test User'), - latestReactions: [ - Reaction( - type: 'love', - messageId: 'test-message', - user: User(id: 'user-1', name: 'User 1'), - createdAt: DateTime.now(), - ), - Reaction( - type: 'like', - messageId: 'test-message', - user: User(id: 'user-2', name: 'User 2'), - createdAt: DateTime.now(), - ), - ], - reactionGroups: { - 'love': ReactionGroup(count: 1, sumScores: 1), - 'like': ReactionGroup(count: 1, sumScores: 1), - }, - ); - - late MockClient mockClient; - - setUp(() { - mockClient = MockClient(); - - final mockClientState = MockClientState(); - when(() => mockClient.state).thenReturn(mockClientState); - - // Mock the current user for the message reactions test - final currentUser = OwnUser(id: 'current-user', name: 'Current User'); - when(() => mockClientState.currentUser).thenReturn(currentUser); - }); - - tearDown(() => reset(mockClient)); - - group('StreamMessageReactionsModal', () { - testWidgets( - 'renders message widget and reactions correctly', - (tester) async { - await tester.pumpWidget( - _wrapWithMaterialApp( - client: mockClient, - StreamMessageReactionsModal( - message: message, - messageWidget: const Text('Message Widget'), - ), - ), - ); - - // Use a longer timeout to ensure everything is rendered - await tester.pumpAndSettle(const Duration(seconds: 2)); - expect(find.text('Message Widget'), findsOneWidget); - // Check for reaction picker - expect(find.byType(StreamReactionPicker), findsOneWidget); - // Check for reaction details - expect(find.byType(StreamUserReactions), findsOneWidget); - }, - ); - - testWidgets( - 'calls onUserAvatarTap when user avatar is tapped', - (tester) async { - User? tappedUser; - - // Render the reactions modal and assert avatar tap callback behavior. - await tester.pumpWidget( - _wrapWithMaterialApp( - client: mockClient, - StreamMessageReactionsModal( - message: message, - messageWidget: const Text('Message Widget'), - onUserAvatarTap: (user) { - tappedUser = user; - }, - ), - ), - ); - - await tester.pumpAndSettle(); - - final avatar = find.descendant( - of: find.byType(StreamUserReactions), - matching: find.byType(StreamUserAvatar), - ); - - final avatarTapTarget = find.ancestor( - of: avatar.first, - matching: find.byWidgetPredicate( - (widget) => widget is GestureDetector && widget.child is StreamUserAvatar, - ), - ); - - // Verify the avatar widgets and scoped tap target are rendered. - expect(avatar, findsNWidgets(2)); - expect(avatarTapTarget, findsOneWidget); - - final gestureDetector = tester.widget(avatarTapTarget); - expect(gestureDetector.onTap, isNotNull); - - // Invoke only the tap target that wraps the first avatar. - gestureDetector.onTap!.call(); - await tester.pump(); - - // Verify the callback was called. - expect(tappedUser, isNotNull); - }, - ); - - testWidgets( - 'pops with SelectReaction when reaction is selected', - (tester) async { - MessageAction? messageAction; - - // Define a custom reaction resolver for testing. - const testReactionResolver = _TestReactionIconResolver( - defaultReactionTypes: {'like', 'love', 'camera', 'call'}, - iconByType: { - 'like': Icons.thumb_up, - 'love': Icons.favorite, - 'camera': Icons.camera, - 'call': Icons.call, - }, - ); - - await tester.pumpWidget( - _wrapWithMaterialApp( - client: mockClient, - reactionIconResolver: testReactionResolver, - Builder( - builder: (context) => TextButton( - onPressed: () async { - messageAction = await showStreamDialog( - context: context, - builder: (_) => StreamMessageReactionsModal( - message: message, - messageWidget: const Text('Message Widget'), - ), - ); - }, - child: const Text('Open Dialog'), - ), - ), - ), - ); - - await tester.tap(find.text('Open Dialog')); - - // Use a longer timeout to ensure everything is rendered - await tester.pumpAndSettle(const Duration(seconds: 1)); - - // Verify reaction picker is shown - expect(find.byType(StreamReactionPicker), findsOneWidget); - - // Find and tap the camera reaction - final reactionIconFinder = find.byIcon(Icons.camera); - expect(reactionIconFinder, findsOneWidget); - await tester.tap(reactionIconFinder); - await tester.pumpAndSettle(); - - expect(messageAction, isA()); - // Verify the popped value has correct reaction type - expect((messageAction! as SelectReaction).reaction.type, 'camera'); - - // Open dialog again and tap the call reaction - await tester.tap(find.text('Open Dialog')); - await tester.pumpAndSettle(const Duration(seconds: 1)); - - final callIconFinder = find.byIcon(Icons.call); - expect(callIconFinder, findsOneWidget); - await tester.tap(callIconFinder); - await tester.pumpAndSettle(); - - expect(messageAction, isA()); - // Verify the popped value has correct reaction type - expect((messageAction! as SelectReaction).reaction.type, 'call'); - }, - ); - }); - - group('StreamMessageReactionsModal Golden Tests', () { - Widget buildMessageWidget({bool reverse = false}) { - return Builder( - builder: (context) { - final theme = StreamChatTheme.of(context); - final messageTheme = theme.getMessageTheme(reverse: reverse); - - return Container( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - color: messageTheme.messageBackgroundColor, - ), - child: Text( - message.text ?? '', - style: messageTheme.messageTextStyle, - ), - ); - }, - ); - } - - for (final brightness in Brightness.values) { - final theme = brightness.name; - - goldenTest( - 'StreamMessageReactionsModal in $theme theme', - fileName: 'stream_message_reactions_modal_$theme', - constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600), - builder: () => _wrapWithMaterialApp( - client: mockClient, - brightness: brightness, - StreamMessageReactionsModal( - message: message, - messageWidget: buildMessageWidget(), - ), - ), - ); - - goldenTest( - 'StreamMessageReactionsModal reversed in $theme theme', - fileName: 'stream_message_reactions_modal_reversed_$theme', - constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600), - builder: () => _wrapWithMaterialApp( - client: mockClient, - brightness: brightness, - StreamMessageReactionsModal( - message: message, - messageWidget: buildMessageWidget(reverse: true), - reverse: true, - ), - ), - ); - } - }); -} - -Widget _wrapWithMaterialApp( - Widget child, { - required StreamChatClient client, - Brightness? brightness, - ReactionIconResolver? reactionIconResolver, -}) { - return MaterialApp( - debugShowCheckedModeBanner: false, - theme: ThemeData(brightness: brightness), - builder: (context, child) => Portal( - child: StreamChat( - client: client, - // Mock the connectivity stream to always return wifi. - connectivityStream: Stream.value([ConnectivityResult.wifi]), - streamChatThemeData: StreamChatThemeData(brightness: brightness), - streamChatConfigData: StreamChatConfigurationData( - reactionIconResolver: reactionIconResolver ?? const _TestReactionIconResolver(), - ), - child: child, - ), - ), - home: Builder( - builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: ColoredBox( - color: theme.colorTheme.overlay, - child: Padding( - padding: const EdgeInsets.all(8), - child: child, - ), - ), - ); - }, - ), - ); -} - -class _TestReactionIconResolver extends ReactionIconResolver { - const _TestReactionIconResolver({ - this.defaultReactionTypes = const {'like', 'love', 'haha', 'wow', 'sad'}, - this.iconByType = const {}, - }); - - final Set defaultReactionTypes; - final Map iconByType; - - @override - Set get defaultReactions => defaultReactionTypes; - - @override - Set get supportedReactions => { - ...defaultReactionTypes, - ...iconByType.keys, - }; - - @override - String? emojiCode(String type) => streamSupportedEmojis[type]?.emoji; - - @override - Widget resolve(BuildContext context, String type) { - if (iconByType[type] case final icon?) { - return Icon(icon); - } - - if (emojiCode(type) case final emoji?) { - return Text(emoji); - } - - return const Text('❓'); - } -} diff --git a/packages/stream_chat_flutter/test/src/message_widget/deleted_message_test.dart b/packages/stream_chat_flutter/test/src/message_widget/deleted_message_test.dart deleted file mode 100644 index 1768a5cbc3..0000000000 --- a/packages/stream_chat_flutter/test/src/message_widget/deleted_message_test.dart +++ /dev/null @@ -1,214 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; -import '../mocks.dart'; - -void main() { - testWidgets('control test', (tester) async { - final client = MockClient(); - final clientState = MockClientState(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: const Scaffold( - body: StreamDeletedMessage( - messageTheme: StreamMessageThemeData( - createdAtStyle: TextStyle( - color: Colors.black, - ), - messageTextStyle: TextStyle(), - ), - ), - ), - ), - ), - ); - - expect(find.text('Message deleted'), findsOneWidget); - }); - - goldenTest( - 'control golden light', - fileName: 'deleted_message_light', - constraints: const BoxConstraints.tightFor(width: 200, height: 200), - builder: () { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ - 'name': 'test', - }); - - when(() => clientState.totalUnreadCount).thenReturn(10); - when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(10)); - - final materialTheme = ThemeData.light( - useMaterial3: false, - ); - final theme = StreamChatThemeData.fromTheme(materialTheme); - return MaterialAppWrapper( - theme: materialTheme, - home: StreamChat( - streamChatThemeData: theme, - client: client, - connectivityStream: Stream.value([ConnectivityResult.mobile]), - child: StreamChannel( - showLoading: false, - channel: channel, - child: Scaffold( - body: Center( - child: StreamDeletedMessage( - messageTheme: theme.ownMessageTheme, - ), - ), - ), - ), - ), - ); - }, - ); - - goldenTest( - 'control golden dark', - fileName: 'deleted_message_dark', - constraints: const BoxConstraints.tightFor(width: 200, height: 200), - builder: () { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ - 'name': 'test', - }); - - when(() => clientState.totalUnreadCount).thenReturn(10); - when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(10)); - - final materialTheme = ThemeData.dark( - useMaterial3: false, - ); - final theme = StreamChatThemeData.fromTheme(materialTheme); - return MaterialAppWrapper( - theme: materialTheme, - home: StreamChat( - streamChatThemeData: theme, - client: client, - connectivityStream: Stream.value([ConnectivityResult.mobile]), - child: StreamChannel( - showLoading: false, - channel: channel, - child: Scaffold( - body: Center( - child: StreamDeletedMessage( - messageTheme: theme.ownMessageTheme, - ), - ), - ), - ), - ), - ); - }, - ); - - goldenTest( - 'golden customization test', - fileName: 'deleted_message_custom', - constraints: const BoxConstraints.tightFor(width: 200, height: 200), - builder: () { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ - 'name': 'test', - }); - - when(() => clientState.totalUnreadCount).thenReturn(10); - when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(10)); - - final materialTheme = ThemeData.light( - useMaterial3: false, - ); - - var theme = StreamChatThemeData.fromTheme(materialTheme); - theme = theme.copyWith( - ownMessageTheme: theme.ownMessageTheme.copyWith( - messageDeletedStyle: theme.ownMessageTheme.messageTextStyle!.copyWith( - fontWeight: FontWeight.bold, - color: Colors.red, - ), - ), - ); - - return MaterialAppWrapper( - theme: materialTheme, - home: StreamChat( - streamChatThemeData: theme, - client: client, - connectivityStream: Stream.value([ConnectivityResult.mobile]), - child: StreamChannel( - showLoading: false, - channel: channel, - child: Scaffold( - body: Center( - child: StreamDeletedMessage( - messageTheme: theme.ownMessageTheme, - reverse: true, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ), - ), - ); - }, - ); -} diff --git a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_custom.png b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_custom.png deleted file mode 100644 index 3fcf5ac84b8d27d0b28d0e54db74b93eeb4f6888..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 945 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k4M?tyST_$yu@pObhHwBu4M$1`kk42gi^K9h!lVkHA-`qRLZ~4peN3U;Z?%B7m_w|>V1>Dn8CH?)6 z7uHyunRn`r!?s-M__(;^KWk>1vgX~W+b=zTp8WE4YQfhxey$W|uYXmuujg~+Y^(On zMxYgYYHH`2+LS+f`ug>izc02p_$~ilxb@`&>vh*(SJcLix#hp(zn8DCE}vij>-w`p_3uyX@BjDqew_UyIhJ>g4hkGBO?Zhz zTY`P<>gyz*cQZ@>*!Q>2yzbA#%2PKkhfTP9?$Ocs&G+9f>$NqUP$_=4c4p;zAMPg) z6YZ~+&ICF&DRz3HMa=;Rpr_CK_it`{?q_3f+m|}?yTaUEf)+C09vAGG`FZnBLw@su zdW$`?W^dLxT~TGz_xNM8eB9q^yZR3sfk9gKI&aPHtIzo##NBaQRhy94_vEmvTrcNin!rB4S=+#9WRv?QG_vpB)C;b1omK zKKYcDxp+o#oY@0+^VQ-F*=hnDOpOY|gfIx>wVS_x+xPzSp^tyMzw_IETjIFt=2#5h zoxbPz@6hARpI?dIc=+~f>Q}D)&zc+rI9M795Ef@F^|jf4i+At+)6E+0r69n<zopr07+t&DgXcg diff --git a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_light.png b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_light.png deleted file mode 100644 index 16071e2b62c404f2e0c0f141f17a127a7ac6d668..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 688 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k4M?tyST_$yu@pObhHwBu4M$1`kk42g+h$J=x7zjsgDjp4gv z_ZU>5RpaSW-L^Y*S`UUPte!$E(RearRJ-n~?Nxk$rtqxRv$##38oubsEe z;Z4czyLRjj2W*Z>Tz_5q-jd?`|rQM z)sfTe|KHxwz;lqvh=u!vfR2JQ5u6);ir!<(!R|iF7Jd8Bhn0G1I%XdMET6{Sj2Tus+Rl3B-?HA_^eOp31C)X@O1TaS?83{1OTw?7!m*g diff --git a/packages/stream_chat_flutter/test/src/message_widget/message_text_test.dart b/packages/stream_chat_flutter/test/src/message_widget/message_text_test.dart deleted file mode 100644 index 47949604af..0000000000 --- a/packages/stream_chat_flutter/test/src/message_widget/message_text_test.dart +++ /dev/null @@ -1,253 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; -import '../mocks.dart'; -import '../simple_frame.dart'; - -void expectTextStrings(Iterable widgets, List strings) { - var currentString = 0; - for (final widget in widgets) { - if (widget is RichText) { - final span = widget.text as TextSpan; - final text = _extractTextFromTextSpan(span); - expect(text, equals(strings[currentString])); - currentString += 1; - } - } -} - -String _extractTextFromTextSpan(TextSpan span) { - var text = span.text ?? ''; - if (span.children != null) { - for (final child in span.children! as Iterable) { - text += _extractTextFromTextSpan(child); - } - } - return text; -} - -void main() { - testWidgets( - 'it should show correct message text', - (WidgetTester tester) async { - final currentUser = OwnUser(id: 'user-id'); - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(currentUser); - when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser)); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ - 'name': 'test', - }); - - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamMessageText( - message: Message( - text: 'demo', - ), - messageTheme: streamTheme.otherMessageTheme, - ), - ), - ), - ), - ), - ); - - // wait for the initial state to be rendered. - await tester.pumpAndSettle(); - - expect(find.byType(MarkdownBody), findsOneWidget); - }, - ); - - group('Message with i18n field', () { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - const messageTheme = StreamMessageThemeData(); - - final currentUser = OwnUser( - id: 'sahil', - language: 'hi', - ); - - setUp(() { - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(currentUser); - when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser)); - - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); - }); - - testWidgets( - 'should show correct translated message text as per user language', - (WidgetTester tester) async { - final message = Message( - text: 'Hello', - i18n: const { - 'en_text': 'Hello', - 'hi_text': 'नमस्ते', - 'language': 'en', - }, - ); - - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamMessageText( - message: message, - messageTheme: messageTheme, - ), - ), - ), - ), - ), - ); - - // wait for the initial state to be rendered. - await tester.pump(Duration.zero); - - expect(find.byType(MarkdownBody), findsOneWidget); - - final widgets = tester.allWidgets; - expectTextStrings(widgets, ['नमस्ते']); - }, - ); - - testWidgets( - '''should show default text if i18n does not contain translations as per user language''', - (WidgetTester tester) async { - final message = Message( - text: 'Hello', - i18n: const { - 'en_text': 'Hello', - 'fr_text': 'Bonjour', - 'language': 'en', - }, - ); - - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamMessageText( - message: message, - messageTheme: messageTheme, - ), - ), - ), - ), - ), - ); - - // wait for the initial state to be rendered. - await tester.pump(Duration.zero); - - expect(find.byType(MarkdownBody), findsOneWidget); - - final widgets = tester.allWidgets; - expectTextStrings(widgets, ['Hello']); - }, - ); - }); - - goldenTest( - 'control test', - fileName: 'message_text', - constraints: const BoxConstraints.tightFor(width: 300, height: 200), - builder: () { - final currentUser = OwnUser(id: 'user-id'); - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(currentUser); - when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser)); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ - 'name': 'test', - }); - - const messageText = ''' -a message. -with multiple lines -and a list: -- a. okasd -- b lllll - -cool.'''; - - return MaterialAppWrapper( - home: SimpleFrame( - child: StreamChat( - client: client, - connectivityStream: Stream.value([ConnectivityResult.wifi]), - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamMessageText( - message: Message( - text: messageText, - ), - messageTheme: streamTheme.otherMessageTheme, - ), - ), - ), - ), - ), - ); - }, - ); -} diff --git a/packages/stream_chat_flutter/test/src/message_widget/username_test.dart b/packages/stream_chat_flutter/test/src/message_widget/username_test.dart deleted file mode 100644 index e37f5bfe72..0000000000 --- a/packages/stream_chat_flutter/test/src/message_widget/username_test.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/message_widget/username.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -void main() { - testWidgets('Username', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Center( - child: Username( - message: Message(), - textStyle: StreamChatThemeData.light().ownMessageTheme.messageAuthorStyle!, - ), - ), - ), - ), - ); - - expect(find.byType(Text), findsOneWidget); - }); -} diff --git a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/reaction_indicator_icon_list_dark.png b/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/reaction_indicator_icon_list_dark.png deleted file mode 100644 index 819784cbb0bc9febfc47b4991227c781baa195dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1644 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 QlwS@&xwt!XoPzKD3pn5w1ONa4 diff --git a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/reaction_indicator_icon_list_selected_dark.png b/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/reaction_indicator_icon_list_selected_dark.png deleted file mode 100644 index e9bae86872ed3f4d4ae61ca4a1f99fa210596a0d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1783 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 diff --git a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_dark.png b/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_dark.png deleted file mode 100644 index da6b13b24c01dae8865071a529bda070f2877767..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1660 zcma)7`(F}<76%1HB1L5r6<_(Bn$px>FjVq^UBMKcn3?2b4HZ<3%zRFY>CLOvvX!$; zGp&)YQh9}nZ0Z$1K8A~&Wm#DwsMwZfCXMd>1MWSa^Z0y!IOlUd=X1X2R2(9QZHHOI z006-DaAI(@J`U>*2ZiXpQ^MN|`T)u$gvUVjQV2~g*JEIAbQl59HilLK01(&k;GmdO zbxV)sWSclNM4QF5OW{;lAOw49?sqPEc$u?QJ3dQRrX}5o7OM`hWAFG(ST4ABMT3HB z+@n4Z?|1nQMHIZl^L-N$CMO-g=UM$!7+}1Cwizu6)n$&4?hD&|J{##+zCb)Z1x9rH zLe3Ds13E_20?Uyo#18QPgwje;DQ{qByX*@IX9el_z=L4s00$#r^9=XG`9IS|#v}!h z6gQhL0?G~!bQI9mQxR%AK^LQM1a&_5HbW9xw7z}uS3cCR-Q}Yb5>Lh+Yx!$AYatYV zG99`)`_qC*m-dhJO*F>gI6{z?DbX1oWq2+|-lIbAjrKoV3$ZN-i5y_KwP`O66>v>>6N;=OIyFa zH<)O&fX0Ch8=oQL=E41KZQiM0UhToZ+FSKv@9;PGJG{055D>e%-wscub^Aktr8^}* zEUm7tR+$XphjK7@eDOrqqtN}8z^H7VeQE)X_H2SaMMi>vUtX=$6#Tw^;h|y~g#h>U zI?YrBKOX@(U&WzV&;2uIHwD|aw*+Gp!WZpmZF#cG0rt&@+RQj!>^^h;4_4eszmucs|LvjNg*m)7I0%8u z%Db+gqoE+Mvt^ouie93co(C^4yJmfHDX#sfv}cK^7D9U%FXrVx8f|eIO)`CpSbyj0 z13&-n@ldJ6?sx8Cq_EkawW$pNa8IDvEm!ljXbg2e#hLl|;*wA(Y^F3_8}uWMj*rWW z8&`NlwF(3u-X$_x4&1XA+W#=!A6KvjzZ2uJc#piZ=HQ%4(ZUi`GXEg1mDsZ58PybK zot}=rd-v|}(8VpjRh30@Ao=$C zt34Bbw)6jb4Ja+dS9f>&M?^%>GBe?AYP-W-z>f(XbB%KY-J;GsSPjV?Q;_gy@Ug?#Rb;iNj+D2cJ1&6~SwkSFnHmVcWzb2dM37hvO#si$F zG<}s^cx2>wiBRp|E1^@fHllg`wDio837p68OAG}I%ykKBRXzDLT je1UNYhQXo#2h`f5eKNc?hbRmB_W=kGAqTe+Xk~u`Nr3rF diff --git a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_fallback_dark.png b/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_fallback_dark.png deleted file mode 100644 index 296e23bc961a83752b19ee5f938faba5d6f0cc57..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1523 zcmah}dogt%;b5 zl9k8u3YCswl}E{IZj2(2ymfc)-<@;6=X}4P^ZkC$_j^8ne9nh;#Mwbr8LkWffGWw6 z;3mTtvb2VPW%XCz#Vi>p#NbKp5LqNZjx%MwT#TCo9(d5HI|%@a1tfy4dwl-q5AU6I z9CX36f4T%zY}Zwh!|#V3^|A2SvkOAfCmca=-2%C8H1P$gN3o5AhwFf{K9cBEWUs8G zra*}S(|SL~m_k6uc#Q0?JvwBGUruM5m^$xd{?f{R+8zLHEQlBCxRA;K1PP6>;) z^hNj!Cl-UjG;XXdWxYHO0JbrR*hP`x63qjR)Oa49w%Ya;J1&vvB@DSx*O3sknc2>P zYAFKunoQQ#f+~GTwRk=fj5O}R)9F~h-h+#O^|ZzLtEj0pqg4Q$?rK!zR9PndrWsNr zzPh3HTVJ0Se*`mHyQi(Kjq~1+!kZ7p>CNVk<;5i?CPG@!i|}SNflhA-U+X*;LO-Ab zCZup{3yw?r2Obe_QhtZa%3&!GDEwhn%Ek7VwOR+R^`h8%_I9nAkk>&TUQ zKisa+4HP*m)<-xCAxNXcmI2OwIl3d1I8Rw9_cRi^3+D;WTSd5z(XHX zge%*CptEPS1=LUqMcEeWnD;z9*FeT(dfIrOrDf}8g2*J@&b-ieV0d`Z?u}((hCrs# zoSeq4uFGC!lJKVVD&v8u$5Aca$|xfvBQHNc2~YL6Al$&6nuZMFoT(o21rErE~l6ZmMq{H~3xBZXx&8pK1mG+q=64GR@4UmY=ga`Pb Glz#yCh_vYd diff --git a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_fallback_light.png b/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_fallback_light.png deleted file mode 100644 index c5e7d0846f5b59529eb29f44c8204c0aae728209..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1744 zcma)7`8OMg7LL3ir3leli;&akq$8?^R*Mv=ps}=Qr^rxBQWTRK6{Ml97Bh)uY_(UP zy$r@!Dj22FmSE^uf>dbwG{m!pkYsY^FX%nz-tXM+r~93IzjM<(-JMmGbd>-AfQk#& z>8cEeWO)&+Agd;RJ6SRSksMsS!LmpMhdh<_a-^%y4uD3M{t5t~Kz4Du9Whrl-{*U+K&L9N zHyw|fsL9X6w{32A9HnxPl#_v`0ze&tKx1SX^aqHG7)pNWpaHquVff?8aLv$EA|Y4k zk8k3Q*YERttc7L0>Vhwb;ys9Dns)LY(l>`{nT1DMkoNU+haW|9X2#wvSD9rObz#dH zbQYt`c_%gUfYO}jCNPbl{dH*CYBE|mM5zg?QJtB>m0qkqS#Ca2uneUODtC=2HQk&8t?3k=!@7N%Vv zj=5;?Ilot=(mpTo>aPHddjc>%8y)$QMtSZmzpY$5^-{n@ZMaQWV-70sl%`v^&7KCr z;GDZz{@L&W^13l)tNO$bQ@#kf=I?jsVLVRapZXlqGPwGIB-|#75bOsJar@;;BbH1_ zR_r;`$3gj^J_MOPsmYloL%8D$C7{%K|3g?+qveZR#4W&60g`C7+ zMxub3Sorq-X>WNS}Gwfy+z zkX!HQZ3Q#Ry;)Lm9e;D>)a=rIwU=1gT!Dw7BvBaMo|VWG#r?->qW>0c!d{$qUbW$i zOZNkrc+hNI;Q?%EDcE1f@HUL^k3DV?f0?5V%XO727a4|D6 zyFP@3IjX2i&`G^y^~1;MJ}(mf_s>>+9}c;GRM5c5=7oncPXeG&=*8Vi=lcmQ`(drl zi0b`?I1dY3Tb1|iA1}x06Ug%~9R!NaXA^nW*%~k7K2Mu6#sgR9HG9Q_L{>?{n_@!_$y(-kHx*c%=IlGImCH@}wY(~Tw!VHy#I}l{ zgAYl64*6|S_uJRhh@QU5?b8T>;m6^^v%G}y<&ub38^L;wFyA5D6`Z;BZ{-lglYb4U z<{`=Q+f6EnHb=uX(LS1UcaPDTzbI>&P0c>B(pJ#cp5*I3tE#ltUCy|@IcV`(Dnc+7 zXxVQ=hIKc`QhAwFOBbR5_m|0NQ@p3YMd5O(Nm($bM+_&E3E_0;(Z}G_{+c?y;CSZj zo?~&)2X4&kGDHDtx9k-b!&X2S;KTTssn^o1LoX zi`iFNx1a=yWe)OA>och-iQu)fxBe-}))e6~50=Qt@ Kof;hiQ~m+N;zZ*B diff --git a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_light.png b/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_light.png deleted file mode 100644 index b9abc15b6cd3dbd07e8eca42a4405088394ad9c8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1938 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 Glz#yCh_vYd diff --git a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_own_light.png b/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_own_light.png deleted file mode 100644 index c5e7d0846f5b59529eb29f44c8204c0aae728209..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1744 zcma)7`8OMg7LL3ir3leli;&akq$8?^R*Mv=ps}=Qr^rxBQWTRK6{Ml97Bh)uY_(UP zy$r@!Dj22FmSE^uf>dbwG{m!pkYsY^FX%nz-tXM+r~93IzjM<(-JMmGbd>-AfQk#& z>8cEeWO)&+Agd;RJ6SRSksMsS!LmpMhdh<_a-^%y4uD3M{t5t~Kz4Du9Whrl-{*U+K&L9N zHyw|fsL9X6w{32A9HnxPl#_v`0ze&tKx1SX^aqHG7)pNWpaHquVff?8aLv$EA|Y4k zk8k3Q*YERttc7L0>Vhwb;ys9Dns)LY(l>`{nT1DMkoNU+haW|9X2#wvSD9rObz#dH zbQYt`c_%gUfYO}jCNPbl{dH*CYBE|mM5zg?QJtB>m0qkqS#Ca2uneUODtC=2HQk&8t?3k=!@7N%Vv zj=5;?Ilot=(mpTo>aPHddjc>%8y)$QMtSZmzpY$5^-{n@ZMaQWV-70sl%`v^&7KCr z;GDZz{@L&W^13l)tNO$bQ@#kf=I?jsVLVRapZXlqGPwGIB-|#75bOsJar@;;BbH1_ zR_r;`$3gj^J_MOPsmYloL%8D$C7{%K|3g?+qveZR#4W&60g`C7+ zMxub3Sorq-X>WNS}Gwfy+z zkX!HQZ3Q#Ry;)Lm9e;D>)a=rIwU=1gT!Dw7BvBaMo|VWG#r?->qW>0c!d{$qUbW$i zOZNkrc+hNI;Q?%EDcE1f@HUL^k3DV?f0?5V%XO727a4|D6 zyFP@3IjX2i&`G^y^~1;MJ}(mf_s>>+9}c;GRM5c5=7oncPXeG&=*8Vi=lcmQ`(drl zi0b`?I1dY3Tb1|iA1}x06Ug%~9R!NaXA^nW*%~k7K2Mu6#sgR9HG9Q_L{>?{n_@!_$y(-kHx*c%=IlGImCH@}wY(~Tw!VHy#I}l{ zgAYl64*6|S_uJRhh@QU5?b8T>;m6^^v%G}y<&ub38^L;wFyA5D6`Z;BZ{-lglYb4U z<{`=Q+f6EnHb=uX(LS1UcaPDTzbI>&P0c>B(pJ#cp5*I3tE#ltUCy|@IcV`(Dnc+7 zXxVQ=hIKc`QhAwFOBbR5_m|0NQ@p3YMd5O(Nm($bM+_&E3E_0;(Z}G_{+c?y;CSZj zo?~&)2X4&kGDHDtx9k-b!&X2S;KTTssn^o1LoX zi`iFNx1a=yWe)OA>och-iQu)fxBe-}))e6~50=Qt@ Kof;hiQ~m+N;zZ*B diff --git a/packages/stream_chat_flutter/test/src/reactions/indicator/reaction_indicator_test.dart b/packages/stream_chat_flutter/test/src/reactions/indicator/reaction_indicator_test.dart deleted file mode 100644 index cf7ea2c28c..0000000000 --- a/packages/stream_chat_flutter/test/src/reactions/indicator/reaction_indicator_test.dart +++ /dev/null @@ -1,494 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -void main() { - const resolver = _TestReactionIconResolver(); - - testWidgets( - 'renders with correct message and reaction icons', - (WidgetTester tester) async { - final message = Message( - id: 'test-message', - text: 'Hello world', - user: User(id: 'test-user'), - reactionGroups: { - 'love': ReactionGroup( - count: 3, - sumScores: 3, - firstReactionAt: DateTime.now(), - lastReactionAt: DateTime.now(), - ), - 'thumbsUp': ReactionGroup( - count: 2, - sumScores: 2, - firstReactionAt: DateTime.now(), - lastReactionAt: DateTime.now(), - ), - }, - ); - - await tester.pumpWidget( - _wrapWithMaterialApp( - StreamReactionIndicator( - message: message, - ), - reactionIconResolver: resolver, - ), - ); - - await tester.pumpAndSettle(); - - // Verify the widget renders with correct structure. - expect(find.byType(StreamReactionIndicator), findsOneWidget); - expect(find.byType(StreamEmoji), findsNWidgets(2)); - }, - ); - - testWidgets( - 'triggers onTap callback when tapped', - (WidgetTester tester) async { - final message = Message( - id: 'test-message', - text: 'Hello world', - user: User(id: 'test-user'), - reactionGroups: { - 'love': ReactionGroup( - count: 1, - sumScores: 1, - firstReactionAt: DateTime.now(), - lastReactionAt: DateTime.now(), - ), - }, - ); - - var tapped = false; - - await tester.pumpWidget( - _wrapWithMaterialApp( - StreamReactionIndicator( - message: message, - onTap: () { - tapped = true; - }, - ), - reactionIconResolver: resolver, - ), - ); - - await tester.pumpAndSettle(); - - // Tap the indicator. - await tester.tap(find.byType(InkWell)); - await tester.pump(); - - // Verify the callback was called. - expect(tapped, isTrue); - }, - ); - - testWidgets( - 'renders no emojis when reactionGroups are missing', - (WidgetTester tester) async { - final message = Message( - id: 'test-message', - text: 'Hello world', - user: User(id: 'test-user'), - ); - - await tester.pumpWidget( - _wrapWithMaterialApp( - StreamReactionIndicator( - message: message, - ), - reactionIconResolver: resolver, - ), - ); - - await tester.pumpAndSettle(); - - expect(find.byType(StreamEmoji), findsNothing); - }, - ); - - testWidgets( - 'updates emoji count when reaction groups change', - (WidgetTester tester) async { - final initialMessage = Message( - id: 'test-message', - text: 'Hello world', - user: User(id: 'test-user'), - reactionGroups: { - 'love': ReactionGroup( - count: 1, - sumScores: 1, - firstReactionAt: DateTime.now(), - lastReactionAt: DateTime.now(), - ), - }, - ); - - final updatedMessage = Message( - id: 'test-message', - text: 'Hello world', - user: User(id: 'test-user'), - reactionGroups: { - 'love': ReactionGroup( - count: 1, - sumScores: 1, - firstReactionAt: DateTime.now(), - lastReactionAt: DateTime.now(), - ), - 'like': ReactionGroup( - count: 1, - sumScores: 1, - firstReactionAt: DateTime.now(), - lastReactionAt: DateTime.now(), - ), - 'wow': ReactionGroup( - count: 1, - sumScores: 1, - firstReactionAt: DateTime.now(), - lastReactionAt: DateTime.now(), - ), - }, - ); - - await tester.pumpWidget( - _wrapWithMaterialApp( - StreamReactionIndicator(message: initialMessage), - reactionIconResolver: resolver, - ), - ); - - await tester.pumpAndSettle(); - // Initially only one reaction group is visible. - expect(find.byType(StreamEmoji), findsOneWidget); - - await tester.pumpWidget( - _wrapWithMaterialApp( - StreamReactionIndicator(message: updatedMessage), - reactionIconResolver: resolver, - ), - ); - - await tester.pumpAndSettle(); - // Updated message contains three reaction groups. - expect(find.byType(StreamEmoji), findsNWidgets(3)); - }, - ); - - testWidgets( - 'respects custom reaction sorting', - (WidgetTester tester) async { - final message = Message( - id: 'test-message', - text: 'Hello world', - user: User(id: 'test-user'), - reactionGroups: { - 'love': ReactionGroup( - count: 5, - sumScores: 5, - firstReactionAt: DateTime(2026, 1, 1, 10, 0), - lastReactionAt: DateTime(2026, 1, 1, 10, 0), - ), - 'like': ReactionGroup( - count: 1, - sumScores: 1, - firstReactionAt: DateTime(2026, 1, 1, 9, 0), - lastReactionAt: DateTime(2026, 1, 1, 9, 0), - ), - }, - ); - - await tester.pumpWidget( - _wrapWithMaterialApp( - StreamReactionIndicator( - message: message, - reactionSorting: ReactionSorting.byCount, - ), - reactionIconResolver: resolver, - ), - ); - - await tester.pumpAndSettle(); - - // Validate display order for custom sorting (ascending count). - final rendered = tester.widgetList( - find.byType(StreamEmoji), - ); - - final first = rendered.first.props.emoji as Text; - final second = rendered.elementAt(1).props.emoji as Text; - - expect(first.data, resolver.emojiCode('like')); - expect(second.data, resolver.emojiCode('love')); - }, - ); - - testWidgets( - 'uses custom reaction resolver rendering', - (WidgetTester tester) async { - final message = Message( - id: 'test-message', - text: 'Hello world', - user: User(id: 'test-user'), - reactionGroups: { - 'customParty': ReactionGroup( - count: 1, - sumScores: 1, - firstReactionAt: DateTime.now(), - lastReactionAt: DateTime.now(), - ), - }, - ); - - await tester.pumpWidget( - _wrapWithMaterialApp( - StreamReactionIndicator(message: message), - reactionIconResolver: const _TypeBasedReactionIconResolver(), - ), - ); - - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('custom-type-customParty')), findsOneWidget); - }, - ); - - testWidgets( - 'renders resolver fallback for unsupported reaction type', - (WidgetTester tester) async { - final message = Message( - id: 'test-message', - text: 'Hello world', - user: User(id: 'test-user'), - reactionGroups: { - 'customUnsupported': ReactionGroup( - count: 1, - sumScores: 1, - firstReactionAt: DateTime.now(), - lastReactionAt: DateTime.now(), - ), - }, - ); - - await tester.pumpWidget( - _wrapWithMaterialApp( - StreamReactionIndicator(message: message), - reactionIconResolver: const _StrictReactionIconResolver(), - ), - ); - - await tester.pumpAndSettle(); - - expect(find.byType(StreamEmoji), findsOneWidget); - expect(find.text('❓'), findsOneWidget); - }, - ); - - group('Golden tests', () { - for (final brightness in [Brightness.light, Brightness.dark]) { - final theme = brightness.name; - - goldenTest( - 'StreamReactionIndicator in $theme theme', - fileName: 'stream_reaction_indicator_$theme', - constraints: const BoxConstraints.tightFor(width: 200, height: 60), - builder: () { - final message = Message( - id: 'test-message', - text: 'Hello world', - user: User(id: 'test-user'), - reactionGroups: { - 'love': ReactionGroup( - count: 3, - sumScores: 3, - firstReactionAt: DateTime.now(), - lastReactionAt: DateTime.now(), - ), - 'thumbsUp': ReactionGroup( - count: 2, - sumScores: 2, - firstReactionAt: DateTime.now(), - lastReactionAt: DateTime.now(), - ), - }, - ); - - return _wrapWithMaterialApp( - brightness: brightness, - StreamReactionIndicator( - message: message, - ), - reactionIconResolver: resolver, - ); - }, - ); - - goldenTest( - 'StreamReactionIndicator with own reaction in $theme theme', - fileName: 'stream_reaction_indicator_own_$theme', - constraints: const BoxConstraints.tightFor(width: 200, height: 60), - builder: () { - final message = Message( - id: 'test-message', - text: 'Hello world', - user: User(id: 'test-user'), - reactionGroups: { - 'love': ReactionGroup( - count: 1, - sumScores: 1, - firstReactionAt: DateTime.now(), - lastReactionAt: DateTime.now(), - ), - }, - ownReactions: [ - Reaction( - type: 'love', - messageId: 'test-message', - userId: 'test-user', - ), - ], - ); - - return _wrapWithMaterialApp( - brightness: brightness, - StreamReactionIndicator( - message: message, - ), - reactionIconResolver: resolver, - ); - }, - ); - - goldenTest( - 'StreamReactionIndicator with resolver fallback in $theme theme', - fileName: 'stream_reaction_indicator_fallback_$theme', - constraints: const BoxConstraints.tightFor(width: 200, height: 60), - builder: () { - final message = Message( - id: 'test-message', - text: 'Hello world', - user: User(id: 'test-user'), - reactionGroups: { - 'customUnsupported': ReactionGroup( - count: 1, - sumScores: 1, - firstReactionAt: DateTime.now(), - lastReactionAt: DateTime.now(), - ), - }, - ); - - return _wrapWithMaterialApp( - brightness: brightness, - StreamReactionIndicator( - message: message, - ), - reactionIconResolver: const _StrictReactionIconResolver(), - ); - }, - ); - } - }); -} - -Widget _wrapWithMaterialApp( - Widget child, { - Brightness? brightness, - ReactionIconResolver? reactionIconResolver, -}) { - return MaterialApp( - debugShowCheckedModeBanner: false, - theme: ThemeData(brightness: brightness), - builder: (context, child) => StreamChatConfiguration( - data: StreamChatConfigurationData( - reactionIconResolver: reactionIconResolver ?? const _TestReactionIconResolver(), - ), - child: StreamChatTheme( - data: StreamChatThemeData(brightness: brightness), - child: child ?? const SizedBox.shrink(), - ), - ), - home: Builder( - builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.overlay, - body: Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: child, - ), - ), - ); - }, - ), - ); -} - -class _TestReactionIconResolver extends ReactionIconResolver { - const _TestReactionIconResolver(); - - static const _reactionTypes = {'like', 'haha', 'love', 'wow', 'sad'}; - - @override - Set get defaultReactions => _reactionTypes; - - @override - Set get supportedReactions => _reactionTypes; - - @override - String? emojiCode(String type) => streamSupportedEmojis[type]?.emoji; - - @override - Widget resolve(BuildContext context, String type) { - return Text(emojiCode(type) ?? type); - } -} - -class _TypeBasedReactionIconResolver extends ReactionIconResolver { - const _TypeBasedReactionIconResolver(); - - @override - Set get defaultReactions => const {'customParty'}; - - @override - Set get supportedReactions => const {'customParty'}; - - @override - String? emojiCode(String type) => null; - - @override - Widget resolve(BuildContext context, String type) { - return SizedBox.square(key: Key('custom-type-$type')); - } -} - -class _StrictReactionIconResolver extends ReactionIconResolver { - const _StrictReactionIconResolver(); - - @override - Set get defaultReactions => const {'love'}; - - @override - Set get supportedReactions => const {'love'}; - - @override - String? emojiCode(String type) => streamSupportedEmojis[type]?.emoji; - - @override - Widget resolve(BuildContext context, String type) { - if (!supportedReactions.contains(type)) { - return const Text('❓'); - } - - if (emojiCode(type) case final emoji?) { - return Text(emoji); - } - - return const Text('❓'); - } -} diff --git a/packages/stream_chat_flutter/test/src/reactions/reaction_bubble_overlay_test.dart b/packages/stream_chat_flutter/test/src/reactions/reaction_bubble_overlay_test.dart deleted file mode 100644 index 80eccd5a27..0000000000 --- a/packages/stream_chat_flutter/test/src/reactions/reaction_bubble_overlay_test.dart +++ /dev/null @@ -1,219 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_portal/flutter_portal.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/reactions/reaction_bubble_overlay.dart'; - -void main() { - testWidgets( - 'returns child directly when not visible', - (tester) async { - await tester.pumpWidget( - const MaterialApp( - home: Portal( - child: Scaffold( - body: ReactionBubbleOverlay( - visible: false, - reaction: Text('reaction'), - child: Text('child'), - ), - ), - ), - ), - ); - - expect(find.text('child'), findsOneWidget); - expect(find.text('reaction'), findsNothing); - expect(find.byType(PortalTarget), findsNothing); - }, - ); - - testWidgets( - 'shows portal target when visible', - (tester) async { - await tester.pumpWidget( - const MaterialApp( - home: Portal( - child: Scaffold( - body: ReactionBubbleOverlay( - visible: true, - reaction: Text('reaction'), - child: Text('child'), - ), - ), - ), - ), - ); - - expect(find.text('child'), findsOneWidget); - expect(find.text('reaction'), findsOneWidget); - expect(find.byType(PortalTarget), findsOneWidget); - }, - ); - - testWidgets( - 'supports custom anchor', - (tester) async { - await tester.pumpWidget( - const MaterialApp( - home: Portal( - child: Scaffold( - body: ReactionBubbleOverlay( - visible: true, - anchor: ReactionBubbleAnchor.topStart(offset: Offset(4, -8)), - reaction: Text('reaction'), - child: Text('child'), - ), - ), - ), - ), - ); - - final portalTarget = tester.widget(find.byType(PortalTarget)); - final anchor = portalTarget.anchor as Aligned; - - expect(anchor.target, Alignment.topLeft); - expect(anchor.follower, Alignment.bottomCenter); - expect(anchor.offset, const Offset(4, -8)); - expect(anchor.shiftToWithinBound.x, isTrue); - expect(anchor.shiftToWithinBound.y, isFalse); - expect(find.text('child'), findsOneWidget); - }, - ); - - testWidgets( - 'forwards payload and callback to custom builder', - (tester) async { - String? capturedPayload; - ValueSetter? capturedCallback; - String? pickedValue; - - await tester.pumpWidget( - MaterialApp( - home: Portal( - child: Scaffold( - body: GenericBubbleOverlay( - payload: 'payload-value', - onPicked: (value) { - pickedValue = value; - }, - reactionBuilder: (context, payload, onPicked) { - capturedPayload = payload; - capturedCallback = onPicked; - return const Text('reaction'); - }, - child: const Text('child'), - ), - ), - ), - ), - ); - - expect(capturedPayload, 'payload-value'); - expect(capturedCallback, isNotNull); - - capturedCallback?.call('picked-value'); - expect(pickedValue, 'picked-value'); - }, - ); - - testWidgets( - 'uses start anchor when reverse is false in LTR', - (tester) async { - await tester.pumpWidget( - const MaterialApp( - home: Portal( - child: Scaffold( - body: GenericBubbleOverlay( - payload: 'payload', - reverse: false, - anchorOffset: Offset(12, -6), - reactionBuilder: _defaultReactionBuilder, - child: Text('child'), - ), - ), - ), - ), - ); - - final portalTarget = tester.widget(find.byType(PortalTarget)); - final anchor = portalTarget.anchor as Aligned; - - expect(anchor.target, Alignment.topLeft); - expect(anchor.follower, Alignment.bottomLeft); - expect(anchor.offset, const Offset(12, -6)); - }, - ); - - testWidgets( - 'uses end anchor when reverse is true in LTR', - (tester) async { - await tester.pumpWidget( - const MaterialApp( - home: Portal( - child: Scaffold( - body: GenericBubbleOverlay( - payload: 'payload', - reverse: true, - anchorOffset: Offset(-3, 9), - reactionBuilder: _defaultReactionBuilder, - child: Text('child'), - ), - ), - ), - ), - ); - - final portalTarget = tester.widget(find.byType(PortalTarget)); - final anchor = portalTarget.anchor as Aligned; - - expect(anchor.target, Alignment.topRight); - expect(anchor.follower, Alignment.bottomRight); - expect(anchor.offset, const Offset(-3, 9)); - }, - ); -} - -typedef GenericReactionBuilder = Widget Function(BuildContext context, String payload, ValueSetter? onPicked); - -class GenericBubbleOverlay extends StatelessWidget { - const GenericBubbleOverlay({ - super.key, - required this.payload, - required this.reactionBuilder, - required this.child, - this.onPicked, - this.visible = true, - this.reverse = false, - this.anchorOffset = Offset.zero, - }); - - final String payload; - final GenericReactionBuilder reactionBuilder; - final ValueSetter? onPicked; - final Widget child; - final bool visible; - final bool reverse; - final Offset anchorOffset; - - @override - Widget build(BuildContext context) { - return ReactionBubbleOverlay( - visible: visible, - anchor: ReactionBubbleAnchor( - offset: anchorOffset, - follower: AlignmentDirectional(reverse ? 1 : -1, 1), - target: AlignmentDirectional(reverse ? 1 : -1, -1), - ), - reaction: reactionBuilder(context, payload, onPicked), - child: child, - ); - } -} - -Widget _defaultReactionBuilder( - BuildContext context, - String payload, - ValueSetter? onPicked, -) { - return const SizedBox.shrink(); -} diff --git a/sample_app/ios/Runner.xcodeproj/project.pbxproj b/sample_app/ios/Runner.xcodeproj/project.pbxproj index 3debb7913b..5543119cb8 100644 --- a/sample_app/ios/Runner.xcodeproj/project.pbxproj +++ b/sample_app/ios/Runner.xcodeproj/project.pbxproj @@ -296,7 +296,7 @@ "${BUILT_PRODUCTS_DIR}/package_info_plus/package_info_plus.framework", "${BUILT_PRODUCTS_DIR}/path_provider_foundation/path_provider_foundation.framework", "${BUILT_PRODUCTS_DIR}/photo_manager/photo_manager.framework", - "${BUILT_PRODUCTS_DIR}/record_darwin/record_darwin.framework", + "${BUILT_PRODUCTS_DIR}/record_ios/record_ios.framework", "${BUILT_PRODUCTS_DIR}/sentry_flutter/sentry_flutter.framework", "${BUILT_PRODUCTS_DIR}/share_plus/share_plus.framework", "${BUILT_PRODUCTS_DIR}/shared_preferences_foundation/shared_preferences_foundation.framework", @@ -340,7 +340,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_foundation.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/photo_manager.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/record_darwin.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/record_ios.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sentry_flutter.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/share_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/shared_preferences_foundation.framework", diff --git a/sample_app/lib/app.dart b/sample_app/lib/app.dart index d37248f6a1..d1e0441752 100644 --- a/sample_app/lib/app.dart +++ b/sample_app/lib/app.dart @@ -20,6 +20,9 @@ import 'package:sample_app/state/init_data.dart'; import 'package:sample_app/utils/app_config.dart'; import 'package:sample_app/utils/local_notification_observer.dart'; import 'package:sample_app/utils/localizations.dart'; +import 'package:sample_app/widgets/custom_message_actions.dart'; +import 'package:sample_app/widgets/location/location_attachment.dart'; +import 'package:sample_app/widgets/location/location_detail_dialog.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_localizations/stream_chat_localizations.dart'; @@ -469,66 +472,72 @@ class _StreamChatSampleAppState extends State @override Widget build(BuildContext context) { - return StreamComponentFactory( - builders: StreamComponentBuilders( - extensions: streamChatComponentBuilders( - /// Add your custom component builders here. - ), - ), - child: Stack( - alignment: Alignment.center, - children: [ - if (_initNotifier.initData != null) - ChangeNotifierProvider.value( - value: _initNotifier, - builder: (context, child) => Builder( - builder: (context) { - context.watch(); // rebuild on change - return PreferenceBuilder( - preference: _initNotifier.initData!.preferences.getInt( - 'theme', - defaultValue: 0, + return Stack( + alignment: Alignment.center, + children: [ + if (_initNotifier.initData != null) + ChangeNotifierProvider.value( + value: _initNotifier, + builder: (context, child) => Builder( + builder: (context) { + context.watch(); // rebuild on change + return PreferenceBuilder( + preference: _initNotifier.initData!.preferences.getInt( + 'theme', + defaultValue: 0, + ), + builder: (context, snapshot) => MaterialApp.router( + theme: ThemeData( + brightness: .light, + extensions: [StreamTheme.light()], ), - builder: (context, snapshot) => MaterialApp.router( - theme: ThemeData( - brightness: .light, - extensions: [StreamTheme.light()], - ), - darkTheme: ThemeData( - brightness: .dark, - extensions: [StreamTheme.dark()], - ), - themeMode: const { - -1: ThemeMode.dark, - 0: ThemeMode.system, - 1: ThemeMode.light, - }[snapshot], - supportedLocales: const [ - Locale('en'), - Locale('it'), - ], - localizationsDelegates: const [ - AppLocalizationsDelegate(), - GlobalStreamChatLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ], - builder: (context, child) => StreamChat( - client: _initNotifier.initData!.client, - streamChatConfigData: StreamChatConfigurationData( - draftMessagesEnabled: false, + darkTheme: ThemeData( + brightness: .dark, + extensions: [StreamTheme.dark()], + ), + themeMode: const { + -1: ThemeMode.dark, + 0: ThemeMode.system, + 1: ThemeMode.light, + }[snapshot], + supportedLocales: const [ + Locale('en'), + Locale('it'), + ], + localizationsDelegates: const [ + AppLocalizationsDelegate(), + GlobalStreamChatLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + builder: (context, child) => StreamChat( + client: _initNotifier.initData!.client, + componentBuilders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + messageWidget: customMessageWidgetBuilder, ), - child: child, ), - routerConfig: _setupRouter(), + streamChatConfigData: StreamChatConfigurationData( + draftMessagesEnabled: false, + attachmentBuilders: [ + LocationAttachmentBuilder( + onAttachmentTap: (context, location) { + showLocationDetailDialog(context: context, location: location); + }, + ), + ], + ), + + child: child, ), - ); - }, - ), + routerConfig: _setupRouter(), + ), + ); + }, ), - if (!animationCompleted) buildAnimation(), - ], - ), + ), + if (!animationCompleted) buildAnimation(), + ], ); } } diff --git a/sample_app/lib/pages/channel_page.dart b/sample_app/lib/pages/channel_page.dart index e66ea7c48b..54f2896c7b 100644 --- a/sample_app/lib/pages/channel_page.dart +++ b/sample_app/lib/pages/channel_page.dart @@ -8,12 +8,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:sample_app/pages/thread_page.dart'; import 'package:sample_app/routes/routes.dart'; -import 'package:sample_app/widgets/location/location_attachment.dart'; -import 'package:sample_app/widgets/location/location_detail_dialog.dart'; import 'package:sample_app/widgets/location/location_picker_dialog.dart'; import 'package:sample_app/widgets/location/location_picker_option.dart'; -import 'package:sample_app/widgets/message_info_sheet.dart'; -import 'package:sample_app/widgets/reminder_dialog.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; class ChannelPage extends StatefulWidget { @@ -109,9 +105,9 @@ class _ChannelPageState extends State { initialAlignment: widget.initialAlignment, highlightInitialMessage: widget.highlightInitialMessage, onEditMessageTap: _editMessage, - //onMessageSwiped: _reply, + onReplyTap: _reply, messageFilter: defaultFilter, - messageBuilder: customMessageBuilder, + messageBuilder: _messageBuilder, threadBuilder: (_, parentMessage) { return ThreadPage(parent: parentMessage!); }, @@ -209,165 +205,30 @@ class _ChannelPageState extends State { return channel.sendStaticLocation(location: result.coordinates); } - Widget customMessageBuilder( + Widget _messageBuilder( BuildContext context, - MessageDetails details, - List messages, - StreamMessageWidget defaultMessageWidget, + Message message, + StreamMessageWidgetProps defaultProps, ) { - final theme = StreamChatTheme.of(context); - final icons = context.streamIcons; - final textTheme = theme.textTheme; - final colorTheme = theme.colorTheme; + final defaultWidget = StreamMessageWidget.fromProps(props: defaultProps); - final message = details.message; - final reminder = message.reminder; - final channel = StreamChannel.of(context).channel; - final channelConfig = channel.config; - - final currentUser = StreamChat.of(context).currentUser; - final isSentByCurrentUser = message.user?.id == currentUser?.id; - final canDeleteOwnMessage = channel.canDeleteOwnMessage; - - List actionsBuilder( - BuildContext context, - List defaultActions, - ) { - return StreamContextMenuAction.partitioned( - items: [ - ...defaultActions, - if (isSentByCurrentUser && canDeleteOwnMessage) - StreamContextMenuAction.destructive( - label: const Text('Delete Message for Me'), - leading: Icon(icons.trashBin), - onTap: () => _deleteMessageForMe(message), - ), - if (channelConfig?.userMessageReminders == true) ...[ - if (reminder != null) ...[ - StreamContextMenuAction( - label: const Text('Edit Reminder'), - leading: Icon(icons.clock), - onTap: () => _editReminder(message, reminder), - ), - StreamContextMenuAction( - label: const Text('Remove from later'), - leading: Icon(icons.checkmark2), - onTap: () => _removeReminder(message, reminder), - ), - ] else ...[ - StreamContextMenuAction( - label: const Text('Remind me'), - leading: Icon(icons.bellNotification), - onTap: () => _createReminder(message), - ), - StreamContextMenuAction( - label: const Text('Save for later'), - leading: Icon(icons.fileBend), - onTap: () => _createBookmark(message), - ), - ], - ], - if (channelConfig?.deliveryEvents == true) - StreamContextMenuAction( - label: const Text('Message Info'), - leading: Icon(icons.circleInfoTooltip), - onTap: () => _showMessageInfo(message), - ), - ], - ); - } - - final locationAttachmentBuilder = LocationAttachmentBuilder( - onAttachmentTap: (location) => showLocationDetailDialog( - context: context, - location: location, - ), - ); - - final child = Container( - color: reminder != null ? colorTheme.accentPrimary.withOpacity(.1) : null, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (reminder != null) - Align( - alignment: switch (defaultMessageWidget.reverse) { - true => AlignmentDirectional.centerEnd, - false => AlignmentDirectional.centerStart, - }, - child: Padding( - padding: const EdgeInsetsDirectional.fromSTEB(16, 4, 16, 8), - child: Row( - spacing: 4, - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - size: 16, - Icons.bookmark_rounded, - color: colorTheme.accentPrimary, - ), - Text( - 'Saved for later', - style: textTheme.footnote.copyWith( - color: colorTheme.accentPrimary, - ), - ), - ], - ), - ), - ), - defaultMessageWidget.copyWith( - onReplyTap: _reply, - actionsBuilder: actionsBuilder, - showEditMessage: message.sharedLocation == null, - attachmentBuilders: [locationAttachmentBuilder], - onShowMessage: (message, channel) => GoRouter.of(context).goNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - queryParameters: Routes.CHANNEL_PAGE.queryParams(message), - ), - bottomRowBuilderWithDefaultWidget: (_, __, defaultWidget) { - return defaultWidget.copyWith( - deletedBottomRowBuilder: (context, message) { - return const StreamVisibleFootnote(); - }, - ); - }, - ), - // If the message has a reminder, add some space below it. - if (reminder != null) const SizedBox(height: 4), - ], - ), - ); + if (message.isDeleted || message.state.isFailed) return defaultWidget; - // We do not support quoting deleted messages. - if (message.isDeleted || message.state.isFailed) return child; + final alignment = StreamMessagePlacement.alignmentDirectionalOf(context); + final isEnd = alignment == AlignmentDirectional.centerEnd; - // The threshold after which the message is considered swiped. const threshold = 0.2; - final isMyMessage = details.isMyMessage; - // The direction in which the message can be swiped. - final swipeDirection = details.isMyMessage ? SwipeDirection.endToStart : SwipeDirection.startToEnd; - return Swipeable( - key: ValueKey(details.message.id), - direction: swipeDirection, + key: ValueKey(message.id), + direction: isEnd ? SwipeDirection.endToStart : SwipeDirection.startToEnd, swipeThreshold: threshold, - onSwiped: (_) => _reply(details.message), + onSwiped: (_) => _reply(message), backgroundBuilder: (context, details) { - // The alignment of the swipe action. - final alignment = isMyMessage ? AlignmentDirectional.centerEnd : AlignmentDirectional.centerStart; - - // The progress of the swipe action. final progress = math.min(details.progress, threshold) / threshold; - // The offset for the reply icon. var offset = Offset.lerp(const Offset(-24, 0), const Offset(12, 0), progress)!; - - // If the message is mine, we need to flip the offset. - if (isMyMessage) offset = Offset(-offset.dx, -offset.dy); + if (isEnd) offset = Offset(-offset.dx, -offset.dy); return Align( alignment: alignment, @@ -395,80 +256,8 @@ class _ChannelPageState extends State { ), ); }, - child: child, - ); - } - - Future _editReminder( - Message message, - MessageReminder reminder, - ) async { - final option = await showDialog( - context: context, - builder: (_) => EditReminderDialog( - isBookmarkReminder: reminder.remindAt == null, - ), + child: defaultWidget, ); - - if (option == null) return; - final client = StreamChat.of(context).client; - final messageId = message.id; - final remindAt = option.remindAt; - - return client.updateReminder(messageId, remindAt: remindAt).ignore(); - } - - Future _removeReminder( - Message message, - MessageReminder reminder, - ) async { - final client = StreamChat.of(context).client; - final messageId = message.id; - - return client.deleteReminder(messageId).ignore(); - } - - Future _createReminder(Message message) async { - final reminder = await showDialog( - context: context, - builder: (_) => const CreateReminderDialog(), - ); - - if (reminder == null) return; - final client = StreamChat.of(context).client; - final messageId = message.id; - final remindAt = reminder.remindAt; - - return client.createReminder(messageId, remindAt: remindAt).ignore(); - } - - Future _createBookmark(Message message) async { - final client = StreamChat.of(context).client; - final messageId = message.id; - - return client.createReminder(messageId).ignore(); - } - - Future _deleteMessageForMe(Message message) async { - final confirmDelete = await showStreamDialog( - context: context, - builder: (context) => const StreamMessageActionConfirmationModal( - isDestructiveAction: true, - title: Text('Delete for me'), - content: Text('Are you sure you want to delete this message for you?'), - cancelActionTitle: Text('Cancel'), - confirmActionTitle: Text('Delete'), - ), - ); - - if (confirmDelete != true) return; - - final channel = StreamChannel.of(context).channel; - return channel.deleteMessageForMe(message).ignore(); - } - - Future _showMessageInfo(Message message) async { - return MessageInfoSheet.show(context: context, message: message); } bool defaultFilter(Message m) { diff --git a/sample_app/lib/pages/thread_page.dart b/sample_app/lib/pages/thread_page.dart index c43f9c7b50..197fb72152 100644 --- a/sample_app/lib/pages/thread_page.dart +++ b/sample_app/lib/pages/thread_page.dart @@ -1,6 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:sample_app/widgets/location/location_attachment.dart'; -import 'package:sample_app/widgets/location/location_detail_dialog.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; class ThreadPage extends StatefulWidget { @@ -45,13 +43,6 @@ class _ThreadPageState extends State { @override Widget build(BuildContext context) { - final locationAttachmentBuilder = LocationAttachmentBuilder( - onAttachmentTap: (location) => showLocationDetailDialog( - context: context, - location: location, - ), - ); - return Scaffold( backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, appBar: StreamThreadHeader(parent: widget.parent), @@ -62,36 +53,10 @@ class _ThreadPageState extends State { parentMessage: widget.parent, initialScrollIndex: widget.initialScrollIndex, initialAlignment: widget.initialAlignment, - //onMessageSwiped: _reply, + onReplyTap: _reply, messageFilter: defaultFilter, showScrollToBottom: false, highlightInitialMessage: true, - parentMessageBuilder: (context, message, defaultMessage) { - return defaultMessage.copyWith( - attachmentBuilders: [locationAttachmentBuilder], - ); - }, - messageBuilder: (context, details, messages, defaultMessage) { - final message = details.message; - - return defaultMessage.copyWith( - onReplyTap: _reply, - showEditMessage: message.sharedLocation == null, - attachmentBuilders: [locationAttachmentBuilder], - bottomRowBuilderWithDefaultWidget: - ( - context, - message, - defaultWidget, - ) { - return defaultWidget.copyWith( - deletedBottomRowBuilder: (context, message) { - return const StreamVisibleFootnote(); - }, - ); - }, - ); - }, ), ), if (widget.parent.type != 'deleted') diff --git a/sample_app/lib/widgets/custom_message_actions.dart b/sample_app/lib/widgets/custom_message_actions.dart new file mode 100644 index 0000000000..44197a3ec1 --- /dev/null +++ b/sample_app/lib/widgets/custom_message_actions.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:sample_app/widgets/message_info_sheet.dart'; +import 'package:sample_app/widgets/reminder_dialog.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Custom [StreamComponentBuilder] for [StreamMessageWidgetProps] that +/// composes app-specific message action customizations via a delegation +/// chain. +/// +/// Delegation chain: +/// ``` +/// customMessageWidgetBuilder +/// → _ReminderActions (remind me, save for later, edit/remove reminder) +/// → _DeleteForMeAction (delete message for current user only) +/// → _MessageInfoAction (show message delivery info sheet) +/// ``` +Widget customMessageWidgetBuilder( + BuildContext context, + StreamMessageWidgetProps props, +) { + return DefaultStreamMessage( + props: props.copyWith( + actionsBuilder: (context, defaultActions) { + final message = props.message; + return StreamContextMenuAction.partitioned( + items: [ + ...defaultActions, + ..._ReminderActions.build(context, message), + ..._DeleteForMeAction.build(context, message), + ..._MessageInfoAction.build(context, message), + ], + ); + }, + ), + ); +} + +// --------------------------------------------------------------------------- +// Reminder actions +// --------------------------------------------------------------------------- + +abstract final class _ReminderActions { + static List build( + BuildContext context, + Message message, + ) { + final icons = context.streamIcons; + final channel = StreamChannel.of(context).channel; + final channelConfig = channel.config; + if (channelConfig?.userMessageReminders != true) return const []; + + final reminder = message.reminder; + if (reminder != null) { + return [ + StreamContextMenuAction( + label: const Text('Edit Reminder'), + leading: Icon(icons.clock), + onTap: () => _editReminder(context, message, reminder), + ), + StreamContextMenuAction( + label: const Text('Remove from later'), + leading: Icon(icons.checkmark2), + onTap: () => _removeReminder(context, message), + ), + ]; + } + + return [ + StreamContextMenuAction( + label: const Text('Remind me'), + leading: Icon(icons.bellNotification), + onTap: () => _createReminder(context, message), + ), + StreamContextMenuAction( + label: const Text('Save for later'), + leading: Icon(icons.fileBend), + onTap: () => _createBookmark(context, message), + ), + ]; + } + + static Future _editReminder( + BuildContext context, + Message message, + MessageReminder reminder, + ) async { + final option = await showDialog( + context: context, + builder: (_) => EditReminderDialog( + isBookmarkReminder: reminder.remindAt == null, + ), + ); + + if (option == null) return; + final client = StreamChat.of(context).client; + return client.updateReminder(message.id, remindAt: option.remindAt).ignore(); + } + + static Future _removeReminder( + BuildContext context, + Message message, + ) async { + final client = StreamChat.of(context).client; + return client.deleteReminder(message.id).ignore(); + } + + static Future _createReminder( + BuildContext context, + Message message, + ) async { + final reminder = await showDialog( + context: context, + builder: (_) => const CreateReminderDialog(), + ); + + if (reminder == null) return; + final client = StreamChat.of(context).client; + return client.createReminder(message.id, remindAt: reminder.remindAt).ignore(); + } + + static Future _createBookmark( + BuildContext context, + Message message, + ) async { + final client = StreamChat.of(context).client; + return client.createReminder(message.id).ignore(); + } +} + +// --------------------------------------------------------------------------- +// Delete-for-me action +// --------------------------------------------------------------------------- + +abstract final class _DeleteForMeAction { + static List build( + BuildContext context, + Message message, + ) { + final icons = context.streamIcons; + final channel = StreamChannel.of(context).channel; + final currentUser = StreamChat.of(context).currentUser; + final isSentByCurrentUser = message.user?.id == currentUser?.id; + if (!isSentByCurrentUser || !channel.canDeleteOwnMessage) return const []; + + return [ + StreamContextMenuAction.destructive( + label: const Text('Delete Message for Me'), + leading: Icon(icons.trashBin), + onTap: () => _confirmAndDelete(context, message), + ), + ]; + } + + static Future _confirmAndDelete( + BuildContext context, + Message message, + ) async { + final confirmed = await showStreamDialog( + context: context, + builder: (context) => const StreamMessageActionConfirmationModal( + isDestructiveAction: true, + title: Text('Delete for me'), + content: Text('Are you sure you want to delete this message for you?'), + cancelActionTitle: Text('Cancel'), + confirmActionTitle: Text('Delete'), + ), + ); + + if (confirmed != true) return; + final channel = StreamChannel.of(context).channel; + return channel.deleteMessageForMe(message).ignore(); + } +} + +// --------------------------------------------------------------------------- +// Message info action +// --------------------------------------------------------------------------- + +abstract final class _MessageInfoAction { + static List build( + BuildContext context, + Message message, + ) { + final icons = context.streamIcons; + final channel = StreamChannel.of(context).channel; + if (channel.config?.deliveryEvents != true) return const []; + + return [ + StreamContextMenuAction( + label: const Text('Message Info'), + leading: Icon(icons.circleInfoTooltip), + onTap: () => MessageInfoSheet.show(context: context, message: message), + ), + ]; + } +} diff --git a/sample_app/lib/widgets/location/location_attachment.dart b/sample_app/lib/widgets/location/location_attachment.dart index 5a7038ddd5..979e878aa2 100644 --- a/sample_app/lib/widgets/location/location_attachment.dart +++ b/sample_app/lib/widgets/location/location_attachment.dart @@ -15,7 +15,7 @@ class LocationAttachmentBuilder extends StreamAttachmentWidgetBuilder { /// {@macro locationAttachmentBuilder} const LocationAttachmentBuilder({ this.constraints = _defaultLocationConstraints, - this.padding = const EdgeInsets.all(4), + this.padding = const .symmetric(horizontal: 8), this.onAttachmentTap, }); @@ -26,7 +26,11 @@ class LocationAttachmentBuilder extends StreamAttachmentWidgetBuilder { final EdgeInsetsGeometry padding; /// Optional callback to handle tap events on the attachment. - final ValueSetter? onAttachmentTap; + /// + /// Receives the [BuildContext] from the widget tree where the attachment + /// is rendered, along with the [Location] data. This allows showing + /// dialogs or navigating from the correct context. + final void Function(BuildContext context, Location location)? onAttachmentTap; @override bool canHandle(Message message, _) => message.sharedLocation != null; @@ -47,7 +51,7 @@ class LocationAttachmentBuilder extends StreamAttachmentWidgetBuilder { constraints: constraints, padding: padding, onLocationTap: switch (onAttachmentTap) { - final onTap? => () => onTap(location), + final onTap? => () => onTap(context, location), _ => null, }, ); @@ -62,7 +66,7 @@ class LocationAttachment extends StatelessWidget { required this.user, required this.sharedLocation, this.constraints = _defaultLocationConstraints, - this.padding = const EdgeInsets.all(2), + this.padding = const .symmetric(horizontal: 8), this.onLocationTap, }); From 412d10477a2b981ed4167161bcbb61c095c33cc5 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 18 Mar 2026 18:46:03 +0530 Subject: [PATCH 2/4] refactor(ui)!: rename `StreamReactionPicker` to `StreamMessageReactionPicker` and update related components --- melos.yaml | 5 +- migrations/redesign/reaction_picker.md | 48 ++-- .../stream_chat_flutter/example/lib/main.dart | 126 +++++---- .../stream_chat_component_builders.dart | 2 - .../message_list_view/message_list_view.dart | 2 +- .../message_modal/message_actions_modal.dart | 3 +- .../src/message_widget/message_widget.dart | 2 +- .../src/reactions/picker/reaction_picker.dart | 243 ++++-------------- .../lib/stream_chat_flutter.dart | 5 + packages/stream_chat_flutter/pubspec.yaml | 5 +- .../message_actions_modal_test.dart | 7 +- .../picker/reaction_picker_test.dart | 32 +-- 12 files changed, 177 insertions(+), 303 deletions(-) diff --git a/melos.yaml b/melos.yaml index 680a845248..fde910433b 100644 --- a/melos.yaml +++ b/melos.yaml @@ -93,7 +93,10 @@ command: svg_icon_widget: ^0.0.1 # TODO: Replace with hosted version before merging PR stream_core_flutter: - path: /Users/xsahil03x/StudioProjects/stream-core-flutter/packages/stream_core_flutter + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: c7a31449e8632ea43f8c769be95a30ef9393a792 + path: packages/stream_core_flutter synchronized: ^3.1.0+1 thumblr: ^0.0.4 url_launcher: ^6.3.0 diff --git a/migrations/redesign/reaction_picker.md b/migrations/redesign/reaction_picker.md index c7ad88bd04..fe6c9c7764 100644 --- a/migrations/redesign/reaction_picker.md +++ b/migrations/redesign/reaction_picker.md @@ -10,7 +10,7 @@ This guide covers the migration for the redesigned reaction picker and reaction - [StreamChatConfigurationData](#streamchatconfigurationdata) - [Removed Icon-List APIs](#removed-icon-list-apis) - [ReactionIconResolver and DefaultReactionIconResolver](#reactioniconresolver-and-defaultreactioniconresolver) -- [StreamReactionPicker](#streamreactionpicker) +- [StreamMessageReactionPicker](#streammessagereactionpicker-formerly-streamreactionpicker) - [StreamReactionIndicator](#streamreactionindicator) - [New Components](#new-components) - [Migration Checklist](#migration-checklist) @@ -27,7 +27,8 @@ This guide covers the migration for the redesigned reaction picker and reaction | `DefaultReactionIconResolver` | **New** — ready-to-use default; extend to customize `defaultReactions`, `emojiCode`, or rendering hooks | | `ReactionPickerIconList` / `ReactionIndicatorIconList` | **Removed** — list rendering now lives inside picker/indicator widgets | | `ReactionPickerIcon` / `ReactionIndicatorIcon` | **Removed** — use resolver-based reaction mapping instead | -| `StreamReactionPicker` | **Changed** — reaction set from `config.reactionIconResolver.defaultReactions` only | +| `StreamReactionPicker` | **Renamed** to `StreamMessageReactionPicker` — reaction set from `config.reactionIconResolver.defaultReactions` only | +| `StreamReactionPickerTheme` / `StreamReactionPickerThemeData` | **New** (from `stream_core_flutter`) — theme-based visual customisation for the picker | | `StreamReactionIndicator` | **Changed** — uses `config.reactionIconResolver.resolve(context, type)` only | | `ReactionDetailSheet` | **New** — `ReactionDetailSheet.show()` for reaction details bottom sheet | @@ -204,27 +205,36 @@ class MyReactionIconResolver extends DefaultReactionIconResolver { --- -## StreamReactionPicker +## StreamMessageReactionPicker (formerly StreamReactionPicker) ### Breaking Changes: +- **Renamed** from `StreamReactionPicker` to `StreamMessageReactionPicker` +- `StreamReactionPicker` now refers to the domain-agnostic core component from `stream_core_flutter` - Picker icons are no longer configured with per-widget icon models - Quick-pick entries now come from `config.reactionIconResolver.defaultReactions` +- Visual properties (`backgroundColor`, `padding`, `shape`) removed from the widget — use `StreamReactionPickerTheme` instead +- The core picker now uses a `StreamComponentFactory` pattern with `StreamReactionPickerProps` for full customization ### Migration **Before:** ```dart -StreamChat( - client: client, - streamChatConfigData: StreamChatConfigurationData( - reactionIcons: [ /* old icon list */ ], - ), - child: MyApp(), +StreamReactionPicker( + message: message, ) ``` **After:** +```dart +StreamMessageReactionPicker( + message: message, + onReactionPicked: onReactionPicked, +) +``` + +Configure reactions globally via `reactionIconResolver`: + ```dart StreamChat( client: client, @@ -235,17 +245,23 @@ StreamChat( ) ``` -Then keep picker usage unchanged: +Customize visual appearance via theme: ```dart -StreamReactionPicker( - message: message, - onReactionPicked: onReactionPicked, +StreamReactionPickerTheme( + data: StreamReactionPickerThemeData( + backgroundColor: Colors.white, + elevation: 4, + spacing: 2, + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadius.all(Radius.circular(24)), + ), + side: BorderSide(color: Colors.grey), + ), + child: // ... ) ``` -Set `reactionIconResolver` on `StreamChatConfigurationData` to customize. - --- ## StreamReactionIndicator @@ -315,7 +331,9 @@ Exported for `StreamChatConfigurationData`. See [ReactionIconResolver and Defaul ## Migration Checklist +- [ ] Rename `StreamReactionPicker` → `StreamMessageReactionPicker` in your code - [ ] Remove `reactionIcons` from `StreamChatConfigurationData` +- [ ] Remove `backgroundColor`, `padding`, `shape` props from picker usage — use `StreamReactionPickerTheme` instead - [ ] Custom quick-pick: extend `DefaultReactionIconResolver`, override `defaultReactions` with types from `streamSupportedEmojis` (so `emojiCode` returns emoji); set `reactionIconResolver` - [ ] Custom types not in `streamSupportedEmojis`: also override `emojiCode` to return Unicode emoji for each; optionally `supportedReactions` - [ ] Custom rendering (e.g. Twemoji): extend `DefaultReactionIconResolver`, override `resolve(context, type)` and branch by type, set `reactionIconResolver` diff --git a/packages/stream_chat_flutter/example/lib/main.dart b/packages/stream_chat_flutter/example/lib/main.dart index 5b0ae45d03..5d814dfa7b 100644 --- a/packages/stream_chat_flutter/example/lib/main.dart +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -255,79 +255,73 @@ class _ChannelPageState extends State { child: StreamMessageListView( threadBuilder: (_, parent) => ThreadPage(parent: parent!), messageBuilder: (context, message, defaultProps) { - // The threshold after which the message is considered - // swiped. - const threshold = 0.2; - - final currentUser = StreamChat.of(context).currentUser; - final isMyMessage = message.user?.id == currentUser?.id; - - // The direction in which the message can be swiped. - final swipeDirection = isMyMessage - ? SwipeDirection - .endToStart // - : SwipeDirection.startToEnd; - - return Swipeable( - key: ValueKey(message.id), - direction: swipeDirection, - swipeThreshold: threshold, - onSwiped: (details) => reply(message), - backgroundBuilder: (context, details) { - // The alignment of the swipe action. - final alignment = isMyMessage - ? Alignment - .centerRight // - : Alignment.centerLeft; - - // The progress of the swipe action. - final progress = math.min(details.progress, threshold) / threshold; - - // The offset for the reply icon. - var offset = Offset.lerp( - const Offset(-24, 0), - const Offset(12, 0), - progress, - )!; - - // If the message is mine, we need to flip the offset. - if (isMyMessage) { - offset = Offset(-offset.dx, -offset.dy); - } - - final _streamTheme = StreamChatTheme.of(context); - - return Align( - alignment: alignment, - child: Transform.translate( - offset: offset, - child: Opacity( - opacity: progress, - child: SizedBox.square( - dimension: 30, - child: CustomPaint( - painter: AnimatedCircleBorderPainter( - progress: progress, - color: _streamTheme.colorTheme.borders, - ), - child: Center( - child: Icon( - context.streamIcons.arrowShareLeft, - size: lerpDouble(0, 18, progress), - color: _streamTheme.colorTheme.accentPrimary, - ), - ), + // The threshold after which the message is considered + // swiped. + const threshold = 0.2; + + final currentUser = StreamChat.of(context).currentUser; + final isMyMessage = message.user?.id == currentUser?.id; + + // The direction in which the message can be swiped. + final swipeDirection = isMyMessage ? SwipeDirection.endToStart : SwipeDirection.startToEnd; + + return Swipeable( + key: ValueKey(message.id), + direction: swipeDirection, + swipeThreshold: threshold, + onSwiped: (details) => reply(message), + backgroundBuilder: (context, details) { + // The alignment of the swipe action. + final alignment = isMyMessage ? Alignment.centerRight : Alignment.centerLeft; + + // The progress of the swipe action. + final progress = math.min(details.progress, threshold) / threshold; + + // The offset for the reply icon. + var offset = Offset.lerp( + const Offset(-24, 0), + const Offset(12, 0), + progress, + )!; + + // If the message is mine, we need to flip the offset. + if (isMyMessage) { + offset = Offset(-offset.dx, -offset.dy); + } + + final _streamTheme = StreamChatTheme.of(context); + + return Align( + alignment: alignment, + child: Transform.translate( + offset: offset, + child: Opacity( + opacity: progress, + child: SizedBox.square( + dimension: 30, + child: CustomPaint( + painter: AnimatedCircleBorderPainter( + progress: progress, + color: _streamTheme.colorTheme.borders, + ), + child: Center( + child: Icon( + context.streamIcons.arrowShareLeft, + size: lerpDouble(0, 18, progress), + color: _streamTheme.colorTheme.accentPrimary, ), ), ), ), - ); - }, - child: DefaultStreamMessage( - props: defaultProps.copyWith(onReplyTap: reply), + ), ), ); }, + child: DefaultStreamMessage( + props: defaultProps.copyWith(onReplyTap: reply), + ), + ); + }, ), ), StreamMessageInput( 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 de83a8ac5d..a692917a5f 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 @@ -11,7 +11,6 @@ Iterable> streamChatComponentBuilders({ StreamComponentBuilder? messageComposerInputLeading, StreamComponentBuilder? messageComposerInputHeader, StreamComponentBuilder? messageComposerInputTrailing, - StreamComponentBuilder? reactionPicker, StreamComponentBuilder? messageWidget, }) { final builders = [ @@ -23,7 +22,6 @@ Iterable> streamChatComponentBuilders({ if (messageComposerInputLeading != null) StreamComponentBuilderExtension(builder: messageComposerInputLeading), if (messageComposerInputHeader != null) StreamComponentBuilderExtension(builder: messageComposerInputHeader), if (messageComposerInputTrailing != null) StreamComponentBuilderExtension(builder: messageComposerInputTrailing), - if (reactionPicker != null) StreamComponentBuilderExtension(builder: reactionPicker), if (messageWidget != null) StreamComponentBuilderExtension(builder: messageWidget), ]; diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index 24c8cb717e..51cc86cf6e 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -14,7 +14,7 @@ import 'package:stream_chat_flutter/src/message_list_view/unread_messages_separa import 'package:stream_chat_flutter/src/message_widget/ephemeral_message.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart' hide StreamMessageWidget, StreamMessageWidgetProps; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Spacing Types (These are properties of a message to help inform the decision /// of how much space / which widget to build after it) diff --git a/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart index 18f4a049f4..5c8f3a5596 100644 --- a/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart' show StreamMessagePlacement; /// {@template streamMessageActionsModal} /// A modal that displays a list of actions that can be performed on a message. @@ -68,7 +67,7 @@ class StreamMessageActionsModal extends StatelessWidget { spacing: spacing.xs, alignment: effectiveAlignment, headerBuilder: switch (showReactionPicker) { - true => (context) => StreamReactionPicker( + true => (context) => StreamMessageReactionPicker( message: message, onReactionPicked: onReactionPicked, ), diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart index ff4fc5db09..42f3f253a8 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart @@ -575,7 +575,7 @@ class DefaultStreamMessage extends StatelessWidget { } return [ - if (showPicker) StreamReactionPicker.builder(context, message, onReactionPicked), + if (showPicker) StreamMessageReactionPicker(message: message, onReactionPicked: onReactionPicked), ...actions, ]; } diff --git a/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker.dart b/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker.dart index a2fa8911ba..69146611bf 100644 --- a/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker.dart +++ b/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker.dart @@ -1,5 +1,4 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/components/stream_chat_component_builders.dart'; +import 'package:flutter/widgets.dart'; import 'package:stream_chat_flutter/src/stream_chat_configuration.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_core_flutter/stream_core_flutter.dart'; @@ -9,106 +8,29 @@ import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@endtemplate} typedef OnReactionPicked = ValueSetter; -/// {@template reactionPickerBuilder} -/// Function signature for building a custom reaction picker widget. +/// {@template streamMessageReactionPicker} +/// A chat-specific reaction picker that bridges [StreamReactionPicker] with +/// chat domain models. /// -/// Use this to provide a custom reaction picker in [StreamMessageActionsModal] -/// or [StreamMessageReactionsModal]. +/// Resolves reaction icons via [ReactionIconResolver], tracks the current +/// user's own reactions on the [Message], and wires the "add reaction" button +/// to [StreamEmojiPickerSheet]. /// -/// Parameters: -/// - [context]: The build context. -/// - [message]: The message to show reactions for. -/// - [onReactionPicked]: Callback when a reaction is picked. -/// {@endtemplate} -typedef ReactionPickerBuilder = - Widget Function( - BuildContext context, - Message message, - OnReactionPicked? onReactionPicked, - ); - -/// {@template streamReactionPicker} -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/reaction_picker.png) -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/reaction_picker_paint.png) -/// -/// A widget that displays a horizontal list of reaction icons that users can -/// select to react to a message. -/// -/// The reaction picker can be configured with custom reaction types, padding, -/// border radius, and can be made scrollable or static depending on the -/// specific needs. +/// Visual customisation is controlled through [StreamReactionPickerTheme] in +/// the widget tree. /// /// See also: /// -/// * [StreamReactionPickerProps], which configures this widget. -/// * [DefaultStreamReactionPicker], the default implementation. +/// * [StreamReactionPicker], the domain-agnostic core picker. +/// * [ReactionIconResolver], which maps reaction types to emoji widgets. +/// * [StreamReactionPickerTheme], for customising the picker appearance. /// {@endtemplate} -class StreamReactionPicker extends StatelessWidget { - /// {@macro streamReactionPicker} - StreamReactionPicker({ +class StreamMessageReactionPicker extends StatelessWidget { + /// {@macro streamMessageReactionPicker} + const StreamMessageReactionPicker({ super.key, - required Message message, - OnReactionPicked? onReactionPicked, - Color? backgroundColor, - EdgeInsetsGeometry? padding, - BorderRadiusGeometry? borderRadius, - }) : props = StreamReactionPickerProps( - message: message, - onReactionPicked: onReactionPicked, - backgroundColor: backgroundColor, - padding: padding, - borderRadius: borderRadius, - ); - - /// The properties that configure this reaction picker. - final StreamReactionPickerProps props; - - /// Creates a [StreamReactionPicker] with platform-appropriate defaults. - /// - /// On iOS/Android the picker uses rounded corners; on desktop/web the - /// border radius is set to zero. - static Widget builder( - BuildContext context, - Message message, - OnReactionPicked? onReactionPicked, - ) { - final platform = Theme.of(context).platform; - return switch (platform) { - TargetPlatform.iOS || TargetPlatform.android => StreamReactionPicker( - message: message, - onReactionPicked: onReactionPicked, - ), - _ => StreamReactionPicker( - message: message, - borderRadius: BorderRadius.zero, - onReactionPicked: onReactionPicked, - ), - }; - } - - @override - Widget build(BuildContext context) { - final builder = context.chatComponentBuilder(); - if (builder != null) return builder(context, props); - return DefaultStreamReactionPicker(props: props); - } -} - -/// Properties for configuring a [StreamReactionPicker]. -/// -/// See also: -/// -/// * [StreamReactionPicker], which uses these properties. -/// * [DefaultStreamReactionPicker], the default implementation. -@immutable -class StreamReactionPickerProps { - /// Creates properties for a reaction picker. - const StreamReactionPickerProps({ required this.message, this.onReactionPicked, - this.backgroundColor, - this.padding, - this.borderRadius, }); /// The message to attach the reaction to. @@ -117,118 +39,51 @@ class StreamReactionPickerProps { /// {@macro onReactionPicked} final OnReactionPicked? onReactionPicked; - /// Background color for the reaction picker. - final Color? backgroundColor; - - /// Padding around the reaction picker. - /// - /// When null, defaults to `EdgeInsetsDirectional.only(start: spacing.xxs)`. - final EdgeInsetsGeometry? padding; - - /// Border radius for the reaction picker. - /// - /// When null, defaults to a circular border with radius `xxxxl`. - final BorderRadiusGeometry? borderRadius; -} - -/// The default implementation of [StreamReactionPicker]. -/// -/// Resolves [StreamReactionPickerProps] into a horizontal row of reaction -/// emoji buttons plus an "add reaction" button that opens the emoji picker -/// sheet. -/// -/// See also: -/// -/// * [StreamReactionPicker], the public API widget. -/// * [StreamReactionPickerProps], which configures this widget. -class DefaultStreamReactionPicker extends StatelessWidget { - /// Creates a default reaction picker with the given [props]. - const DefaultStreamReactionPicker({super.key, required this.props}); - - /// The properties that configure this widget. - final StreamReactionPickerProps props; - @override Widget build(BuildContext context) { - final icons = context.streamIcons; - final radius = context.streamRadius; - final spacing = context.streamSpacing; - final colorScheme = context.streamColorScheme; - - final effectivePadding = props.padding ?? EdgeInsetsDirectional.only(start: spacing.xxs); - final effectiveBorderRadius = props.borderRadius ?? BorderRadius.all(radius.xxxxl); - final effectiveBackgroundColor = props.backgroundColor ?? colorScheme.backgroundElevation2; - - final side = BorderSide(color: colorScheme.borderDefault); - final shape = RoundedSuperellipseBorder(borderRadius: effectiveBorderRadius, side: side); - final config = StreamChatConfiguration.of(context); final resolver = config.reactionIconResolver; final reactionTypes = resolver.defaultReactions; - final message = props.message; - final onReactionPicked = props.onReactionPicked; - final ownReactions = [...?message.ownReactions]; final ownReactionsMap = {for (final it in ownReactions) it.type: it}; - final reactionButtons = reactionTypes.map( - (type) => StreamEmojiButton( - key: Key(type), - size: .lg, - emoji: resolver.resolve(context, type), - // If the reaction is present in ownReactions, it is selected. - isSelected: ownReactionsMap[type] != null, - onPressed: () { - final reactionEmojiCode = resolver.emojiCode(type); - final pickedReaction = switch (ownReactionsMap[type]) { - final reaction? => reaction, - _ => Reaction(type: type, emojiCode: reactionEmojiCode), - }; - - return onReactionPicked?.call(pickedReaction); - }, - ), - ); - - final pickerContent = Row( - mainAxisSize: .min, - spacing: spacing.none, - children: [ - // TODO: Re-enable staggered animation when MessageWidget redesign is finalized. - ...reactionButtons, - StreamButton.icon( - key: const Key('add_reaction'), - size: .small, - type: .outline, - style: .secondary, - icon: icons.plusLarge, - onTap: () async { - final selectedReactions = ownReactionsMap.keys.toSet(); - final emoji = await StreamEmojiPickerSheet.show( - context: context, - selectedReactions: selectedReactions, - ); - - if (!context.mounted || emoji == null) return; - - final reaction = Reaction(type: emoji.shortName, emojiCode: emoji.emoji); - return onReactionPicked?.call(reaction); - }, + final items = [ + ...reactionTypes.map( + (type) => StreamReactionPickerItem( + key: type, + emoji: resolver.resolve(context, type), + // If the reaction is present in ownReactions, it is selected. + isSelected: ownReactionsMap[type] != null, ), - ], - ); - - return Material( - shape: shape, - elevation: 3, - clipBehavior: .antiAlias, - color: effectiveBackgroundColor, - child: SingleChildScrollView( - padding: effectivePadding, - scrollDirection: .horizontal, - child: pickerContent, ), + ]; + + void onItemPicked(StreamReactionPickerItem item) { + final reactionEmojiCode = resolver.emojiCode(item.key); + final pickedReaction = switch (ownReactionsMap[item.key]) { + final reaction? => reaction, + _ => Reaction(type: item.key, emojiCode: reactionEmojiCode), + }; + + return onReactionPicked?.call(pickedReaction); + } + + return StreamReactionPicker( + items: items, + onReactionPicked: onItemPicked, + onAddReactionTap: () async { + final selectedReactions = ownReactionsMap.keys.toSet(); + final emoji = await StreamEmojiPickerSheet.show( + context: context, + selectedReactions: selectedReactions, + ); + + if (!context.mounted || emoji == null) return; + + final reaction = Reaction(type: emoji.shortName, emojiCode: emoji.emoji); + return onReactionPicked?.call(reaction); + }, ); } } diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 5402e9d65f..b5b8696cfd 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -27,6 +27,11 @@ export 'package:stream_core_flutter/stream_core_flutter.dart' StreamMessageAlignment, StreamMessagePlacement, StreamMessageStackPosition, + StreamReactionPicker, + StreamReactionPickerItem, + StreamReactionPickerProps, + StreamReactionPickerTheme, + StreamReactionPickerThemeData, StreamReactionsPosition, StreamReactionsType, streamSupportedEmojis; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index b644c780b4..bc6c041c9b 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -60,7 +60,10 @@ dependencies: shimmer: ^3.0.0 stream_chat_flutter_core: ^10.0.0-beta.12 stream_core_flutter: - path: /Users/xsahil03x/StudioProjects/stream-core-flutter/packages/stream_core_flutter + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: c7a31449e8632ea43f8c769be95a30ef9393a792 + path: packages/stream_core_flutter svg_icon_widget: ^0.0.1 synchronized: ^3.1.0+1 theme_extensions_builder_annotation: ^7.1.0 diff --git a/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart index 1128bdba2f..a454847740 100644 --- a/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart +++ b/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart @@ -5,8 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart' - show StreamIconData; +import 'package:stream_core_flutter/stream_core_flutter.dart' show StreamIconData; void main() { final message = Message( @@ -76,7 +75,7 @@ void main() { // Use a longer timeout to ensure everything is rendered await tester.pumpAndSettle(const Duration(seconds: 1)); - expect(find.byType(StreamReactionPicker), findsOneWidget); + expect(find.byType(StreamMessageReactionPicker), findsOneWidget); }); testWidgets( @@ -121,7 +120,7 @@ void main() { await tester.pumpAndSettle(const Duration(seconds: 1)); // Verify reaction picker is shown - expect(find.byType(StreamReactionPicker), findsOneWidget); + expect(find.byType(StreamMessageReactionPicker), findsOneWidget); // Find and tap the first reaction (like) final reactionIconFinder = find.byIcon(Icons.thumb_up); diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/reaction_picker_test.dart b/packages/stream_chat_flutter/test/src/reactions/picker/reaction_picker_test.dart index b8aa9c6bc3..bd39c55e6a 100644 --- a/packages/stream_chat_flutter/test/src/reactions/picker/reaction_picker_test.dart +++ b/packages/stream_chat_flutter/test/src/reactions/picker/reaction_picker_test.dart @@ -18,7 +18,7 @@ void main() { await tester.pumpWidget( _wrapWithMaterialApp( - StreamReactionPicker( + StreamMessageReactionPicker( message: message, onReactionPicked: (_) {}, ), @@ -29,7 +29,7 @@ void main() { await tester.pumpAndSettle(const Duration(seconds: 1)); // Verify the widget renders with correct structure. - expect(find.byType(StreamReactionPicker), findsOneWidget); + expect(find.byType(StreamMessageReactionPicker), findsOneWidget); // Verify the correct number of reaction buttons. expect( find.byType(StreamEmojiButton), @@ -52,7 +52,7 @@ void main() { await tester.pumpWidget( _wrapWithMaterialApp( - StreamReactionPicker( + StreamMessageReactionPicker( message: message, onReactionPicked: (reaction) { pickedReaction = reaction; @@ -96,7 +96,7 @@ void main() { await tester.pumpWidget( _wrapWithMaterialApp( - StreamReactionPicker( + StreamMessageReactionPicker( message: message, onReactionPicked: (reaction) { pickedReaction = reaction; @@ -134,7 +134,7 @@ void main() { await tester.pumpWidget( _wrapWithMaterialApp( - StreamReactionPicker( + StreamMessageReactionPicker( message: message, onReactionPicked: (_) {}, ), @@ -170,7 +170,7 @@ void main() { await tester.pumpWidget( _wrapWithMaterialApp( - StreamReactionPicker( + StreamMessageReactionPicker( message: message, onReactionPicked: (_) {}, ), @@ -184,7 +184,7 @@ void main() { await tester.pumpWidget( _wrapWithMaterialApp( - StreamReactionPicker( + StreamMessageReactionPicker( message: message, onReactionPicked: (_) {}, ), @@ -214,7 +214,7 @@ void main() { await tester.pumpWidget( _wrapWithMaterialApp( - StreamReactionPicker( + StreamMessageReactionPicker( message: message, onReactionPicked: (_) {}, ), @@ -244,7 +244,7 @@ void main() { await tester.pumpWidget( _wrapWithMaterialApp( - StreamReactionPicker( + StreamMessageReactionPicker( message: message, onReactionPicked: (_) {}, ), @@ -270,7 +270,7 @@ void main() { await tester.pumpWidget( _wrapWithMaterialApp( - StreamReactionPicker( + StreamMessageReactionPicker( message: message, onReactionPicked: (_) {}, ), @@ -289,7 +289,7 @@ void main() { final theme = brightness.name; goldenTest( - 'StreamReactionPicker in $theme theme', + 'StreamMessageReactionPicker in $theme theme', fileName: 'stream_reaction_picker_$theme', constraints: const BoxConstraints.tightFor(width: 400, height: 100), builder: () { @@ -301,7 +301,7 @@ void main() { return _wrapWithMaterialApp( brightness: brightness, - StreamReactionPicker( + StreamMessageReactionPicker( message: message, onReactionPicked: (_) {}, ), @@ -311,7 +311,7 @@ void main() { ); goldenTest( - 'StreamReactionPicker with selected reaction in $theme theme', + 'StreamMessageReactionPicker with selected reaction in $theme theme', fileName: 'stream_reaction_picker_selected_$theme', constraints: const BoxConstraints.tightFor(width: 400, height: 100), builder: () { @@ -330,7 +330,7 @@ void main() { return _wrapWithMaterialApp( brightness: brightness, - StreamReactionPicker( + StreamMessageReactionPicker( message: message, onReactionPicked: (_) {}, ), @@ -340,7 +340,7 @@ void main() { ); goldenTest( - 'StreamReactionPicker with subset defaults in $theme theme', + 'StreamMessageReactionPicker with subset defaults in $theme theme', fileName: 'stream_reaction_picker_subset_$theme', constraints: const BoxConstraints.tightFor(width: 400, height: 100), builder: () { @@ -352,7 +352,7 @@ void main() { return _wrapWithMaterialApp( brightness: brightness, - StreamReactionPicker( + StreamMessageReactionPicker( message: message, onReactionPicked: (_) {}, ), From cc91229794fb00ca540c3c135ab51de3749f1574 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 18 Mar 2026 18:51:41 +0530 Subject: [PATCH 3/4] chore: merge fixes --- packages/stream_chat_flutter/lib/src/stream_chat.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/stream_chat_flutter/lib/src/stream_chat.dart b/packages/stream_chat_flutter/lib/src/stream_chat.dart index 4b623f88bb..0ed8077047 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat.dart @@ -216,7 +216,9 @@ class StreamChatState extends State { child = StreamComponentFactory(builders: builders, child: child); } - return PoraStreamChatConfiguration(data: streamChatConfigData, child: child); + return Portal( + child: StreamChatConfiguration(data: streamChatConfigData, child: child), + ); } StreamChatThemeData _getTheme( From ca90b440f8004f1027f00579bd930808fc9cb938 Mon Sep 17 00:00:00 2001 From: xsahil03x <25670178+xsahil03x@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:30:34 +0000 Subject: [PATCH 4/4] chore: Update Goldens --- .../goldens/ci/sending_indicator_0.png | Bin 200 -> 198 bytes .../goldens/ci/sending_indicator_1.png | Bin 200 -> 198 bytes .../goldens/ci/sending_indicator_2.png | Bin 200 -> 198 bytes .../goldens/ci/sending_indicator_3.png | Bin 200 -> 198 bytes ...ons_modal_reversed_with_reactions_dark.png | Bin 6068 -> 6068 bytes ...ns_modal_reversed_with_reactions_light.png | Bin 8695 -> 8695 bytes ...sage_actions_modal_with_reactions_dark.png | Bin 6060 -> 6060 bytes ...age_actions_modal_with_reactions_light.png | Bin 8773 -> 8773 bytes .../ci/stream_poll_options_dialog_dark.png | Bin 9936 -> 11341 bytes .../ci/stream_poll_options_dialog_light.png | Bin 9630 -> 10673 bytes .../ci/stream_poll_interactor_closed_dark.png | Bin 5719 -> 6925 bytes .../stream_poll_interactor_closed_light.png | Bin 5568 -> 5361 bytes .../ci/stream_poll_interactor_dark.png | Bin 7757 -> 8893 bytes .../ci/stream_poll_interactor_light.png | Bin 7522 -> 7290 bytes 14 files changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_0.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_0.png index 07fd25e5c0fef8eaa2c8016e14661136585df5a6..be8989f375e94236113e1c79adc1dca1ef6cd834 100644 GIT binary patch delta 141 zcmX@Xc#Ls^V|{_Ai(^PeW$yu zv*sDL921ao&fr;S*rMj^VOzVP{_QCx!)F(3W_3$AC3EyDnzUU&XB<5rAOX<=)_bWX Z=lXSS`*q^#2N{6C)0NH7Wt~$(695!zH0J;S delta 143 zcmX@cc!F_)V||gQi(^Peg^N6oilhA8nzr0kb*I0nJ9ievd}+&{%48e-*Yp~k~n%5P1-IBQVG?f c=6ixW;2*dB*5x0H7=VDo)78&qol`;+05m={{r~^~ diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_1.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_1.png index 31faaf2e8ab6adb7e9cbdeeb0d09f710b03ab20c..311529e2474b0091ff2970f64e4acb566c943dcb 100644 GIT binary patch delta 141 zcmX@Xc#Ls^V|{_Ai(^PeVa4Qt_9?KTX69TaF1xIcM-JL}zr(UuXc)0@k~P a^QMv-w|&DNcU}e{@N{MKb6Mw<&;$Tzb~mp8 delta 143 zcmX@cc!F_)V||gQi(^PeC^b)3zoBl<4A2_XQs&sFX z!|uNgu{;Y6TaF1xIcLBbms-9Ex7#KEJ9k~?_Isbrf=E)U?sFNmU661}=IB*4fip6l a>Va4Qt_9?KTX69TaF1xIcM-JL}zr(UuXc)0@k~P a^QMv-w|&DNcU}e{@N{MKb6Mw<&;$Tzb~mp8 delta 143 zcmX@cc!F_)V||gQi(^PeC^b)3zoBl<4A2_XQs&sFX z!|uNgu{;Y6TaF1xIcLBbms-9Ex7#KEJ9k~?_Isbrf=E)U?sFNmU661}=IB*4fip6l a>Va4Qt_9?KTX69TaF1xIcM-JL}zr(UuXc)0@k~P a^QMv-w|&DNcU}e{@N{MKb6Mw<&;$Tzb~mp8 delta 143 zcmX@cc!F_)V||gQi(^PeC^b)3zoBl<4A2_XQs&sFX z!|uNgu{;Y6TaF1xIcLBbms-9Ex7#KEJ9k~?_Isbrf=E)U?sFNmU661}=IB*4fip6l a>wAo8xCZh=BvJ-o{o^HIs00f?{elF{r5}E+i Cu@cJw delta 47 zcmezF{M~tj7W3v6%=2U>8!*NXB0Vp^4h$qd{u`SfWXt$&t;ucLK6U; Cyb#s^ delta 46 zcmX@=a@1vm8uR8Q%!_0ueyC%~KPugzPn5ZKB91fH&bF6*2UngH6M B621Tc diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_dark.png index b0c221516224ab0be605bfebfeb922e71a090b04..a71e6327d6726f87eeb1000cf1b4879cb214f752 100644 GIT binary patch literal 11341 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%0DG1kfE8%)Z~er1@W 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 W2)1?0tEG_l$7CBDbhxA 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 diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_dark.png index 8b3a297331fbbab6fc8f0378379bf01f5d6e7de0..5a7a51af8849ebbf0d6e26b7ebdeae3e44eb051e 100644 GIT binary patch literal 6925 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 literal 5719 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 G<39kp+bE>~ literal 5568 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 Xx_{r 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 diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_light.png index 9b1ebdc7521c7b1bb8d622caeb1ba9a5f0d85e0d..7fb7d88921a11e628582d7f27e4dca3a497743ba 100644 GIT binary patch literal 7290 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