diff --git a/melos.yaml b/melos.yaml index 5fb9b8f50f..fde910433b 100644 --- a/melos.yaml +++ b/melos.yaml @@ -95,7 +95,7 @@ command: stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 57785868e62299901361affbddd06f71253e872f + ref: c7a31449e8632ea43f8c769be95a30ef9393a792 path: packages/stream_core_flutter synchronized: ^3.1.0+1 thumblr: ^0.0.4 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/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 7284f89f0f..5d814dfa7b 100644 --- a/packages/stream_chat_flutter/example/lib/main.dart +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -254,83 +254,74 @@ class _ChannelPageState extends State { Expanded( child: StreamMessageListView( threadBuilder: (_, parent) => ThreadPage(parent: parent!), - messageBuilder: - ( - context, - messageDetails, - messages, - defaultWidget, - ) { - // The threshold after which the message is considered - // swiped. - const threshold = 0.2; - - final isMyMessage = messageDetails.isMyMessage; - - // The direction in which the message can be swiped. - final swipeDirection = isMyMessage - ? SwipeDirection - .endToStart // - : SwipeDirection.startToEnd; - - return Swipeable( - key: ValueKey(messageDetails.message.id), - direction: swipeDirection, - swipeThreshold: threshold, - onSwiped: (details) => reply(messageDetails.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, - ), - ), + 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, ), ), ), ), - ); - }, - child: defaultWidget.copyWith(onReplyTap: reply), + ), + ), ); }, + child: DefaultStreamMessage( + props: defaultProps.copyWith(onReplyTap: reply), + ), + ); + }, ), ), StreamMessageInput( 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..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,6 +11,7 @@ Iterable> streamChatComponentBuilders({ StreamComponentBuilder? messageComposerInputLeading, StreamComponentBuilder? messageComposerInputHeader, StreamComponentBuilder? messageComposerInputTrailing, + StreamComponentBuilder? messageWidget, }) { final builders = [ if (channelListItem != null) StreamComponentBuilderExtension(builder: channelListItem), @@ -21,6 +22,7 @@ Iterable> streamChatComponentBuilders({ if (messageComposerInputLeading != null) StreamComponentBuilderExtension(builder: messageComposerInputLeading), if (messageComposerInputHeader != null) StreamComponentBuilderExtension(builder: messageComposerInputHeader), if (messageComposerInputTrailing != null) StreamComponentBuilderExtension(builder: messageComposerInputTrailing), + 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..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 @@ -1,4 +1,3 @@ -// ignore_for_file: lines_longer_than_80_chars import 'dart:async'; import 'dart:math'; @@ -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..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,4 @@ 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'; /// {@template streamMessageActionsModal} @@ -19,9 +17,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 +35,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 +53,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) => StreamMessageReactionPicker( + 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..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 @@ -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) StreamMessageReactionPicker(message: message, onReactionPicked: 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..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,6 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.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'; /// {@template onReactionPicked} @@ -7,106 +8,39 @@ 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) +/// Visual customisation is controlled through [StreamReactionPickerTheme] in +/// the widget tree. /// -/// A widget that displays a horizontal list of reaction icons that users can -/// select to react to a message. +/// See also: /// -/// 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. +/// * [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} - const StreamReactionPicker({ +class StreamMessageReactionPicker extends StatelessWidget { + /// {@macro streamMessageReactionPicker} + const StreamMessageReactionPicker({ super.key, - this.onReactionPicked, required this.message, - this.backgroundColor, - this.padding, - this.borderRadius, + this.onReactionPicked, }); - /// 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. - /// - /// The [onReactionPicked] callback is optional and can be used to handle - /// the reaction selection. - factory StreamReactionPicker.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, - ), - }; - } - - /// Message to attach the reaction to. + /// The message to attach the reaction to. final Message message; - /// {@macro onReactionPressed} + /// {@macro onReactionPicked} final OnReactionPicked? onReactionPicked; - /// Background color for the reaction picker. - final Color? backgroundColor; - - /// Padding around the reaction picker. - /// - /// Defaults to `EdgeInsets.all(4)`. - final EdgeInsetsGeometry? padding; - - /// Border radius for the reaction picker. - /// - /// Defaults to a circular border with a radius of 24. - final BorderRadiusGeometry? borderRadius; - @override Widget build(BuildContext context) { - final icons = context.streamIcons; - final radius = context.streamRadius; - 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 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; @@ -114,63 +48,42 @@ class StreamReactionPicker extends StatelessWidget { 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/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 b24fdbdca2..0ed8077047 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat.dart @@ -37,6 +37,7 @@ class StreamChat extends StatefulWidget { required this.child, this.streamChatThemeData, this.streamChatConfigData, + this.componentBuilders, this.onBackgroundEventReceived, this.backgroundKeepAlive = const Duration(minutes: 1), this.connectivityStream, @@ -54,6 +55,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; @@ -155,39 +186,39 @@ class StreamChatState extends State { @override Widget build(BuildContext context) { final theme = _getTheme(context, widget.streamChatThemeData); - return Portal( - child: 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 Portal( + child: 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..b5b8696cfd 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -24,6 +24,16 @@ export 'package:stream_core_flutter/stream_core_flutter.dart' StreamEmojiSize, StreamEmojiData, StreamEmojiPickerSheet, + StreamMessageAlignment, + StreamMessagePlacement, + StreamMessageStackPosition, + StreamReactionPicker, + StreamReactionPickerItem, + StreamReactionPickerProps, + StreamReactionPickerTheme, + StreamReactionPickerThemeData, + StreamReactionsPosition, + StreamReactionsType, streamSupportedEmojis; export 'src/ai_assistant/ai_typing_indicator_view.dart'; @@ -101,15 +111,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 +138,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 4120ef87c8..bc6c041c9b 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -62,7 +62,7 @@ dependencies: stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 57785868e62299901361affbddd06f71253e872f + ref: c7a31449e8632ea43f8c769be95a30ef9393a792 path: packages/stream_core_flutter svg_icon_widget: ^0.0.1 synchronized: ^3.1.0+1 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 07fd25e5c0..be8989f375 100644 Binary files a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_0.png and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_0.png differ 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 31faaf2e8a..311529e247 100644 Binary files a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_1.png and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_1.png differ diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_2.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_2.png index 31faaf2e8a..311529e247 100644 Binary files a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_2.png and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_2.png differ diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_3.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_3.png index 31faaf2e8a..311529e247 100644 Binary files a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_3.png and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_3.png differ 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_actions_modal_reversed_with_reactions_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png index 564440b438..becc690ec7 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png index 5f37b42e20..bbe907f69d 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png index c57687e3d8..415fcf54fd 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png index 7cb0df34bd..94808ba067 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png differ 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 1a809d7150..0000000000 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_dark.png and /dev/null differ 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 4f907ddec5..0000000000 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_light.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_dark.png deleted file mode 100644 index 15f2d45217..0000000000 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_dark.png and /dev/null differ 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 ee59757e4e..0000000000 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_light.png and /dev/null differ 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..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,7 +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'; +import 'package:stream_core_flutter/stream_core_flutter.dart' show StreamIconData; void main() { final message = Message( @@ -46,6 +46,7 @@ void main() { message: message, messageActions: messageActions, messageWidget: const Text('Message Widget'), + alignment: AlignmentDirectional.centerStart, ), ), ); @@ -66,6 +67,7 @@ void main() { message: message, messageActions: messageActions, messageWidget: const Text('Message Widget'), + alignment: AlignmentDirectional.centerStart, showReactionPicker: true, ), ), @@ -73,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( @@ -102,6 +104,7 @@ void main() { message: message, messageActions: messageActions, messageWidget: const Text('Message Widget'), + alignment: AlignmentDirectional.centerStart, showReactionPicker: true, ), ); @@ -117,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); @@ -183,6 +186,7 @@ void main() { message: message, messageActions: messageActions, messageWidget: buildMessageWidget(), + alignment: AlignmentDirectional.centerStart, ), ), ); @@ -197,6 +201,7 @@ void main() { message: message, messageActions: messageActions, messageWidget: buildMessageWidget(), + alignment: AlignmentDirectional.centerStart, showReactionPicker: true, ), ), @@ -212,7 +217,7 @@ void main() { message: message, messageActions: messageActions, messageWidget: buildMessageWidget(reverse: true), - reverse: true, + alignment: AlignmentDirectional.centerEnd, ), ), ); @@ -227,8 +232,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 3fcf5ac84b..0000000000 Binary files a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_custom.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_dark.png b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_dark.png deleted file mode 100644 index b69e980bc0..0000000000 Binary files a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_dark.png and /dev/null differ 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 16071e2b62..0000000000 Binary files a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_light.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/message_text.png b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/message_text.png deleted file mode 100644 index 819380daba..0000000000 Binary files a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/message_text.png and /dev/null differ 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/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 b0c2215162..a71e6327d6 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_dark.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_light.png index 0dae885750..4f7758a938 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_light.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_light.png differ 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 8b3a297331..5a7a51af88 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_light.png index aeeb9b195f..737b6002b8 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_dark.png index a1b19ad5ac..de4daf4317 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_dark.png differ 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 9b1ebdc752..7fb7d88921 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_light.png differ 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 819784cbb0..0000000000 Binary files a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/reaction_indicator_icon_list_dark.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/reaction_indicator_icon_list_light.png b/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/reaction_indicator_icon_list_light.png deleted file mode 100644 index 9810a5df80..0000000000 Binary files a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/reaction_indicator_icon_list_light.png and /dev/null differ 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 e9bae86872..0000000000 Binary files a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/reaction_indicator_icon_list_selected_dark.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/reaction_indicator_icon_list_selected_light.png b/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/reaction_indicator_icon_list_selected_light.png deleted file mode 100644 index cc5edc4620..0000000000 Binary files a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/reaction_indicator_icon_list_selected_light.png and /dev/null differ 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 fcca635d0b..0000000000 Binary files a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_dark.png and /dev/null differ 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 c3e0907d6f..0000000000 Binary files a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_fallback_dark.png and /dev/null differ 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 c5e7d0846f..0000000000 Binary files a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_fallback_light.png and /dev/null differ 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 b9abc15b6c..0000000000 Binary files a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_light.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_own_dark.png b/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_own_dark.png deleted file mode 100644 index c3e0907d6f..0000000000 Binary files a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_own_dark.png and /dev/null differ 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 c5e7d0846f..0000000000 Binary files a/packages/stream_chat_flutter/test/src/reactions/indicator/goldens/ci/stream_reaction_indicator_own_light.png and /dev/null differ 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/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: (_) {}, ), 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, });