Skip to content

Commit 944c91a

Browse files
authored
fix(ui): hide message replies in thread view and other improvements (#2594)
1 parent e1dd8e2 commit 944c91a

13 files changed

Lines changed: 268 additions & 271 deletions

melos.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ command:
9595
stream_core_flutter:
9696
git:
9797
url: https://github.com/GetStream/stream-core-flutter.git
98-
ref: 72dd75d0a4f9d4e7551ad34639a9a0e70ccd84de
98+
ref: 4b976d340f7ce085c4b41b7322f82555758ce893
9999
path: packages/stream_core_flutter
100100
synchronized: ^3.1.0+1
101101
thumblr: ^0.0.4

migrations/redesign/message_widget.md

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,18 @@ This guide covers migrating the message widget and message list view from the ol
3131
|-----|-----|
3232
| `StreamMessageWidget` (50+ params) | `StreamMessageWidget` (thin shell) + `StreamMessageWidgetProps` |
3333
| `MessageWidgetContent` | `DefaultStreamMessage` + `StreamMessageContent` |
34-
| `BottomRow` | `StreamMessageFooter` |
34+
| `BottomRow` | `StreamMessageMetadata` |
3535
| `StreamMessageText` (message_text.dart) | `StreamMessageText` (components/stream_message_text.dart) |
3636
| `StreamDeletedMessage` | `StreamMessageDeleted` |
3737
| `MessageCard` | `core.StreamMessageBubble` |
3838
| `TextBubble` | `core.StreamMessageBubble` |
39-
| `PinnedMessage` | `streamMessageHeader()` function |
39+
| `PinnedMessage` | `StreamMessageAnnotations` widget |
4040
| `QuotedMessage` | Inline in `StreamMessageContent` |
41-
| `Username` | Inline in `StreamMessageFooter` |
41+
| `Username` | Inline in `StreamMessageMetadata` |
4242
| `SendingIndicatorBuilder` | `StreamMessageSendingStatus` |
4343
| `ThreadReplyPainter` | `core.StreamMessageReplies` |
4444
| `ThreadParticipants` | Inline in `core.StreamMessageReplies` |
45-
| `UserAvatarTransform` | `StreamMessageLeading` |
45+
| `UserAvatarTransform` | `StreamUserAvatar` (inline in `DefaultStreamMessage`) |
4646
| `DisplayWidget` enum | `StreamVisibility` (from theme) |
4747
| `MessageBuilder` typedef | `StreamMessageWidgetBuilder` typedef |
4848
| `ParentMessageBuilder` typedef | `StreamMessageWidgetBuilder` typedef |
@@ -61,10 +61,10 @@ The old design used a single monolithic `StreamMessageWidget` with 50+ parameter
6161
- **`StreamMessageWidget`** — thin shell that resolves the `StreamComponentFactory` and delegates to the factory builder or `DefaultStreamMessage`.
6262
- **`StreamMessageWidgetProps`** — plain data class holding all configuration. Supports `copyWith()`.
6363
- **`DefaultStreamMessage`** — the default rendering implementation. Composes the sub-components below.
64-
- **`StreamMessageContent`** — bubble, attachments, text, reactions, thread replies.
65-
- **`StreamMessageFooter`** — username, timestamp, sending status, edited indicator.
66-
- **`streamMessageHeader()`** — pinned, saved-for-later, show-in-channel annotations.
67-
- **`StreamMessageLeading`** — author avatar.
64+
- **`StreamMessageContent`** — bubble, attachments, text, reactions. Thread replies are passed in as a pre-built widget from `DefaultStreamMessage`.
65+
- **`StreamMessageMetadata`** — username, timestamp, sending status, edited indicator.
66+
- **`StreamMessageAnnotations`** — pinned, saved-for-later, show-in-channel annotations.
67+
- **`StreamUserAvatar`** — author avatar (inline in `DefaultStreamMessage`).
6868
- **`StreamMessageReactions`** — clustered reaction chips around the bubble.
6969
- **`StreamMessageText`** — markdown-rendered message text.
7070
- **`StreamMessageDeleted`** — deleted message placeholder.
@@ -130,13 +130,13 @@ These parameters have been removed entirely. See the **Migration Path** column f
130130
| `showPinButton` | Controlled via channel permissions (`canPinMessage`) |
131131
| `showPinHighlight` | Controlled via `StreamMessageItemThemeData` background color |
132132
| `showReactionPicker` | Removed |
133-
| `showUsername` | Controlled via `StreamMessageItemThemeData.footerVisibility` |
134-
| `showTimestamp` | Controlled via `StreamMessageItemThemeData.footerVisibility` |
135-
| `showEditedLabel` | Controlled via `StreamMessageItemThemeData.footerVisibility` |
136-
| `showSendingIndicator` | Controlled via `StreamMessageItemThemeData.footerVisibility` |
137-
| `showThreadReplyIndicator` | Shown automatically when `replyCount > 0` |
138-
| `showInChannelIndicator` | Shown automatically via `streamMessageHeader()` |
139-
| `showUserAvatar` (`DisplayWidget`) | Controlled via `StreamMessageItemThemeData.leadingVisibility` |
133+
| `showUsername` | Controlled via `StreamMessageItemThemeData.metadataVisibility` |
134+
| `showTimestamp` | Controlled via `StreamMessageItemThemeData.metadataVisibility` |
135+
| `showEditedLabel` | Controlled via `StreamMessageItemThemeData.metadataVisibility` |
136+
| `showSendingIndicator` | Controlled via `StreamMessageItemThemeData.metadataVisibility` |
137+
| `showThreadReplyIndicator` | Controlled via `StreamMessageItemThemeData.repliesVisibility` |
138+
| `showInChannelIndicator` | Shown automatically via `StreamMessageAnnotations` |
139+
| `showUserAvatar` (`DisplayWidget`) | Controlled via `StreamMessageItemThemeData.avatarVisibility` |
140140

141141
#### Builder Callbacks
142142

@@ -147,7 +147,7 @@ These parameters have been removed entirely. See the **Migration Path** column f
147147
| `quotedMessageBuilder` | Use component factory to replace `StreamMessageContent` |
148148
| `deletedMessageBuilder` | Use component factory to replace `StreamMessageContent` |
149149
| `editMessageInputBuilder` | Removed; use `onEditMessageTap` callback instead |
150-
| `bottomRowBuilderWithDefaultWidget` | Use component factory; `StreamMessageFooter` is the new equivalent |
150+
| `bottomRowBuilderWithDefaultWidget` | Use component factory; `StreamMessageMetadata` is the new equivalent |
151151
| `reactionPickerBuilder` | Configured globally via `StreamChatConfigurationData.reactionIconResolver` |
152152
| `reactionIndicatorBuilder` | Replaced by `StreamMessageReactions` component |
153153

@@ -400,12 +400,13 @@ The old per-property visibility booleans are replaced by a structured visibility
400400

401401
```dart
402402
StreamMessageItemThemeData(
403-
leadingVisibility: StreamMessageStyleVisibility(
403+
avatarVisibility: StreamMessageStyleVisibility(
404404
incoming: StreamVisibility.visible,
405405
outgoing: StreamVisibility.gone,
406406
),
407-
headerVisibility: StreamMessageStyleVisibility(...),
408-
footerVisibility: StreamMessageStyleVisibility(...),
407+
annotationVisibility: StreamMessageStyleVisibility(...),
408+
metadataVisibility: StreamMessageStyleVisibility(...),
409+
repliesVisibility: StreamMessageStyleVisibility(...),
409410
410411
incoming: StreamMessageItemStyle(
411412
padding: EdgeInsets.all(4),
@@ -451,17 +452,17 @@ StreamMessageListView(
451452
|---|---|---|
452453
| `message_widget_content.dart` | `MessageWidgetContent` | `DefaultStreamMessage` + `StreamMessageContent` |
453454
| `message_widget_content_components.dart` | Various internal helpers | Merged into `components/` sub-widgets |
454-
| `bottom_row.dart` | `BottomRow` | `StreamMessageFooter` |
455+
| `bottom_row.dart` | `BottomRow` | `StreamMessageMetadata` |
455456
| `message_text.dart` | `StreamMessageText` | `components/stream_message_text.dart` |
456457
| `deleted_message.dart` | `StreamDeletedMessage` | `StreamMessageDeleted` |
457458
| `message_card.dart` | `MessageCard` | `core.StreamMessageBubble` |
458459
| `text_bubble.dart` | `TextBubble` | `core.StreamMessageBubble` |
459-
| `pinned_message.dart` | `PinnedMessage` | `streamMessageHeader()` function |
460+
| `pinned_message.dart` | `PinnedMessage` | `StreamMessageAnnotations` widget |
460461
| `quoted_message.dart` | `QuotedMessage` | Inline in `StreamMessageContent` |
461462
| `thread_painter.dart` | `ThreadReplyPainter` | `core.StreamMessageReplies` |
462463
| `thread_participants.dart` | `ThreadParticipants` | Inline in `core.StreamMessageReplies` |
463-
| `user_avatar_transform.dart` | `UserAvatarTransform` | `StreamMessageLeading` |
464-
| `username.dart` | `Username` | Inline in `StreamMessageFooter` |
464+
| `user_avatar_transform.dart` | `UserAvatarTransform` | `StreamUserAvatar` (inline in `DefaultStreamMessage`) |
465+
| `username.dart` | `Username` | Inline in `StreamMessageMetadata` |
465466
| `sending_indicator_builder.dart` | `SendingIndicatorBuilder` | `StreamMessageSendingStatus` |
466467

467468
---

packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,8 @@ bool _isGroupBoundary(Message message, Message? neighbor) {
146146
/// - There is no text and no quoted reply
147147
/// - There is exactly one attachment or a poll
148148
///
149-
/// The result is [StreamMessageContentKind.emojiOnly] when:
150-
/// - There is no text, no quoted reply, no poll, and no attachments
149+
/// The result is [StreamMessageContentKind.jumbomoji] when:
150+
/// - There is no quoted reply, no poll, and no attachments
151151
/// - The text contains only 1-3 emoji graphemes
152152
StreamMessageContentKind resolveContentKind(Message message) {
153153
final hasText = message.text?.isNotEmpty == true;
@@ -160,8 +160,8 @@ StreamMessageContentKind resolveContentKind(Message message) {
160160
}
161161

162162
if (!hasQuote && attachmentCount == 0) {
163-
final emojiCount = StreamMessageText.emojiOnlyCount(message.text);
164-
if (emojiCount != null && emojiCount <= 3) return .emojiOnly;
163+
final emojiCount = StreamMessageText.emojiCount(message.text);
164+
if (emojiCount != null && emojiCount <= 3) return .jumbomoji;
165165
}
166166

167167
return .standard;
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:jiffy/jiffy.dart';
3+
import 'package:stream_chat_flutter/src/stream_chat.dart';
4+
import 'package:stream_chat_flutter/src/utils/extensions.dart';
5+
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
6+
import 'package:stream_core_flutter/stream_core_flutter.dart' as core;
7+
8+
/// Displays contextual annotations for the given [message].
9+
///
10+
/// Annotations are shown in the following order when applicable:
11+
///
12+
/// 1. **Saved for later** — when a reminder exists without a scheduled time.
13+
/// 2. **Pinned** — when [Message.pinned] is true, showing who pinned it.
14+
/// 3. **Show in channel / Replied to thread** — when [Message.showInChannel]
15+
/// is true. The label adapts based on whether the message list is a
16+
/// channel or thread view, and includes a tappable "View" link that
17+
/// invokes [onViewChannelTap].
18+
/// 4. **Reminder** — when a reminder exists with a scheduled time.
19+
///
20+
/// Returns `null` when no annotations apply, allowing [StreamColumn] to
21+
/// collapse the widget and skip spacing automatically.
22+
///
23+
/// See also:
24+
///
25+
/// * [DefaultStreamMessage], which controls annotation visibility.
26+
class StreamMessageAnnotations extends core.NullableStatelessWidget {
27+
/// Creates message annotations for the given [message].
28+
const StreamMessageAnnotations({
29+
super.key,
30+
required this.message,
31+
this.onViewChannelTap,
32+
});
33+
34+
/// The message whose annotations to display.
35+
final Message message;
36+
37+
/// Called when the "View" link in the show-in-channel annotation is tapped.
38+
final VoidCallback? onViewChannelTap;
39+
40+
@override
41+
Widget? nullableBuild(BuildContext context) {
42+
final translations = context.translations;
43+
final icons = context.streamIcons;
44+
final textTheme = context.streamTextTheme;
45+
final colorScheme = context.streamColorScheme;
46+
final crossAxisAlignment = core.StreamMessageLayout.crossAxisAlignmentOf(context);
47+
48+
Widget? savedForLaterAnnotation;
49+
if (message.reminder case final reminder? when reminder.remindAt == null) {
50+
savedForLaterAnnotation = core.StreamMessageAnnotation(
51+
leading: Icon(icons.save20, color: colorScheme.accentPrimary),
52+
label: Text(translations.savedForLaterLabel, style: TextStyle(color: colorScheme.accentPrimary)),
53+
);
54+
}
55+
56+
Widget? pinnedAnnotation;
57+
if (message.pinned case true) {
58+
final currentUser = StreamChat.of(context).currentUser!;
59+
pinnedAnnotation = core.StreamMessageAnnotation(
60+
leading: Icon(icons.pin20),
61+
label: Text(
62+
translations.pinnedByUserText(
63+
pinnedBy: message.pinnedBy ?? currentUser,
64+
currentUser: currentUser,
65+
),
66+
),
67+
);
68+
}
69+
70+
Widget? showInChannelAnnotation;
71+
if (message.showInChannel case true) {
72+
final listKind = core.StreamMessageLayout.listKindOf(context);
73+
final annotationLabel = switch (listKind) {
74+
.channel => '${translations.repliedToThreadAnnotationLabel} · ',
75+
.thread => '${translations.alsoSentInChannelAnnotationLabel} · ',
76+
};
77+
78+
showInChannelAnnotation = core.StreamMessageAnnotation(
79+
onTap: onViewChannelTap,
80+
leading: Icon(icons.arrowUpRight20),
81+
label: Text.rich(
82+
TextSpan(
83+
text: annotationLabel,
84+
children: [
85+
TextSpan(
86+
text: translations.viewLabel,
87+
style: textTheme.metadataDefault.copyWith(color: colorScheme.textLink),
88+
),
89+
],
90+
),
91+
),
92+
);
93+
}
94+
95+
Widget? reminderAnnotation;
96+
if (message.reminder?.remindAt?.toLocal() case final remindAt?) {
97+
reminderAnnotation = core.StreamMessageAnnotation(
98+
leading: Icon(icons.bell20),
99+
label: Text.rich(
100+
TextSpan(
101+
text: '${translations.reminderSetLabel} · ',
102+
children: [
103+
TextSpan(
104+
text: translations.reminderAtText(Jiffy.parseFromDateTime(remindAt).jm),
105+
style: textTheme.metadataDefault,
106+
),
107+
],
108+
),
109+
),
110+
);
111+
}
112+
113+
final children = [
114+
?savedForLaterAnnotation,
115+
?pinnedAnnotation,
116+
?showInChannelAnnotation,
117+
?reminderAnnotation,
118+
];
119+
120+
if (children.isEmpty) return null;
121+
122+
return core.StreamColumn(
123+
mainAxisSize: .min,
124+
crossAxisAlignment: crossAxisAlignment,
125+
children: children,
126+
);
127+
}
128+
}

packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
22
import 'package:flutter_markdown/flutter_markdown.dart';
33
import 'package:stream_chat_flutter/src/attachment/builder/attachment_widget_builder.dart';
44
import 'package:stream_chat_flutter/src/channel/stream_message_preview_text.dart';
5-
import 'package:stream_chat_flutter/src/components/avatar/stream_user_avatar.dart';
65
import 'package:stream_chat_flutter/src/message_widget/components/stream_message_deleted.dart';
76
import 'package:stream_chat_flutter/src/message_widget/components/stream_message_reactions.dart';
87
import 'package:stream_chat_flutter/src/message_widget/components/stream_message_text.dart';
@@ -11,11 +10,14 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
1110
import 'package:stream_core_flutter/stream_core_flutter.dart' as core;
1211

1312
/// Composes the main message content including the bubble, attachments, text,
14-
/// reactions, and thread reply indicator.
13+
/// and reactions.
1514
///
1615
/// For deleted messages a [StreamMessageDeleted] placeholder is shown.
17-
/// Otherwise the content displays attachments, message text, reactions, and
18-
/// a thread reply indicator (when [Message.replyCount] is greater than zero).
16+
/// Otherwise the content displays attachments, message text, and reactions.
17+
///
18+
/// The [annotation], [metadata], and [replies] slots are passed in from
19+
/// [DefaultStreamMessage] and rendered in the appropriate positions via the
20+
/// core [core.StreamMessageContent] layout.
1921
///
2022
/// When the message consists of three or fewer emoji-only characters, the
2123
/// bubble background is hidden so the emoji appear at a larger visual size.
@@ -30,31 +32,37 @@ class StreamMessageContent extends StatefulWidget {
3032
const StreamMessageContent({
3133
super.key,
3234
required this.message,
33-
this.header,
34-
this.footer,
35+
this.annotation,
36+
this.metadata,
37+
this.replies,
3538
this.attachmentBuilders,
3639
this.onLinkTap,
3740
this.onMentionTap,
3841
this.onReactionsTap,
39-
this.onRepliesTap,
4042
this.onQuotedMessageTap,
4143
this.reactionSorting,
4244
});
4345

4446
/// The message to display.
4547
final Message message;
4648

47-
/// Optional header widget displayed above the message content column.
49+
/// Optional annotation widget displayed above the message content column.
4850
///
49-
/// Typically a [streamMessageHeader] result containing pinned, reminder,
51+
/// Typically a [StreamMessageAnnotations] containing pinned, reminder,
5052
/// or show-in-channel annotations.
51-
final Widget? header;
53+
final Widget? annotation;
5254

53-
/// Optional footer widget displayed below the message content column.
55+
/// Optional metadata widget displayed below the message content column.
5456
///
55-
/// Typically a [StreamMessageFooter] containing the author name, timestamp,
57+
/// Typically a [StreamMessageMetadata] containing the author name, timestamp,
5658
/// and sending status.
57-
final Widget? footer;
59+
final Widget? metadata;
60+
61+
/// Optional replies indicator widget displayed below the bubble.
62+
///
63+
/// Typically a [core.StreamMessageReplies] showing reply count and
64+
/// participant avatars.
65+
final Widget? replies;
5866

5967
/// Custom attachment builders for rendering message attachments.
6068
///
@@ -77,11 +85,6 @@ class StreamMessageContent extends StatefulWidget {
7785
/// If null, tapping reactions has no effect.
7886
final VoidCallback? onReactionsTap;
7987

80-
/// Called when the thread reply indicator is tapped.
81-
///
82-
/// If null, tapping the reply indicator has no effect.
83-
final VoidCallback? onRepliesTap;
84-
8588
/// Called when the quoted message is tapped.
8689
///
8790
/// If null, tapping the quoted message has no effect.
@@ -120,14 +123,13 @@ class _StreamMessageContentState extends State<StreamMessageContent> {
120123
@override
121124
Widget build(BuildContext context) {
122125
final spacing = context.streamSpacing;
123-
final contentKind = core.StreamMessageLayout.contentKindOf(context);
124126
final crossAxisAlignment = core.StreamMessageLayout.crossAxisAlignmentOf(context);
125127

126128
if (widget.message.isDeleted) return const StreamMessageDeleted();
127129

128130
return core.StreamMessageContent(
129-
header: widget.header,
130-
footer: widget.footer,
131+
header: widget.annotation,
132+
footer: widget.metadata,
131133
child: core.StreamColumn(
132134
mainAxisSize: .min,
133135
crossAxisAlignment: crossAxisAlignment,
@@ -192,16 +194,7 @@ class _StreamMessageContentState extends State<StreamMessageContent> {
192194
},
193195
),
194196
),
195-
if (widget.message.replyCount case final replyCount? when replyCount > 0)
196-
core.StreamMessageReplies(
197-
maxAvatars: 3,
198-
onTap: widget.onRepliesTap,
199-
showConnector: contentKind != .emojiOnly,
200-
label: Text('$replyCount replies'),
201-
avatars: widget.message.threadParticipants?.map(
202-
(user) => StreamUserAvatar(user: user, showOnlineIndicator: false),
203-
),
204-
),
197+
if (widget.replies case final replies?) replies,
205198
],
206199
),
207200
);

0 commit comments

Comments
 (0)