Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
## [Unreleased]

* **Fix**: [423](https://github.com/SimformSolutionsPvtLtd/chatview/pull/423)
* **Fix**: [128](https://github.com/SimformSolutionsPvtLtd/chatview/issues/128)
Rendering issue in attached image preview when sending message on web.
* **Feat**: [420](https://github.com/SimformSolutionsPvtLtd/chatview/pull/420) Added support for
`playerMode` in `VoiceMessageConfiguration` with `single` and `multi`.
* * **Feat**: [440](https://github.com/SimformSolutionsPvtLtd/chatview/pull/420) feat: :sparkles: Implement consecutive message grouping.

## [3.0.0]

Expand Down
28 changes: 28 additions & 0 deletions doc/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,34 @@ ChatView(
lastSeenAgoBuilderVisibility: false,
receiptsBuilderVisibility: false,
enableTextSelection: true,
enableConsecutiveMessageGrouping: true, // grouping feature
),
// ...
)
```

## Consecutive Message Grouping

When `enableConsecutiveMessageGrouping` is set to `true`, ChatView detects runs of consecutive messages that share the **same sender** and were sent on the **same calendar day**, then applies three visual changes that make the conversation feel more compact and easier to scan — similar to the behaviour seen in iMessage, WhatsApp, and Telegram.

### Visual changes

| Element | Normal (grouping off) | Grouped (grouping on) |
| -------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Sender name | Shown above **every** incoming bubble | Shown only above the **first** bubble in the run |
| Profile avatar | Shown beside **every** bubble | Shown only beside the **last** bubble in the run; hidden (but still laid out) for others so alignment is preserved |

### Usage

```dart
ChatView(
chatController: _chatController,
featureActiveConfig: const FeatureActiveConfig(
enableConsecutiveMessageGrouping: true,
// Keep sender names visible so the first bubble in each run
// still identifies who is speaking.
enableOtherUserName: true,
enableOtherUserProfileAvatar: true,
),
// ...
)
Expand Down
9 changes: 9 additions & 0 deletions lib/src/models/config_models/feature_active_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class FeatureActiveConfig {
this.enableOtherUserName = true,
this.enableScrollToBottomButton = false,
this.enableTextSelection = false,
this.enableConsecutiveMessageGrouping = false,
});

/// Used for enable/disable swipe to reply.
Expand Down Expand Up @@ -85,4 +86,12 @@ class FeatureActiveConfig {
///
/// Defaults to `false`.
final bool enableTextSelection;

/// When `true`, consecutive messages from the same author on the same day
/// are visually grouped: the sender name is shown only on the first message
/// of the run, the profile avatar is shown only on the last, and the
/// inter-message margin is tightened.
///
/// Defaults to `false`.
final bool enableConsecutiveMessageGrouping;
}
51 changes: 47 additions & 4 deletions lib/src/widgets/chat_bubble_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ class ChatBubbleWidget extends StatefulWidget {
required this.onSwipe,
this.onReplyTap,
this.shouldHighlight = false,
this.isFirstInGroup = true,
this.isLastInGroup = true,
}) : super(key: key);

/// Represent current instance of message.
Expand All @@ -63,6 +65,20 @@ class ChatBubbleWidget extends StatefulWidget {
/// Flag for when user tap on replied message and highlight actual message.
final bool shouldHighlight;

/// Whether this message is the first in a consecutive run of messages from
/// the same sender on the same day. When [true], the sender name is shown
/// above the bubble.
///
/// Defaults to `true` (safe fallback: always show name).
final bool isFirstInGroup;

/// Whether this message is the last in a consecutive run of messages from
/// the same sender on the same day. When [true], the profile avatar is
/// visible and a larger bottom margin is applied.
///
/// Defaults to `true` (safe fallback: always show avatar).
final bool isLastInGroup;

@override
State<ChatBubbleWidget> createState() => _ChatBubbleWidgetState();
}
Expand All @@ -75,6 +91,10 @@ class _ChatBubbleWidgetState extends State<ChatBubbleWidget> {
bool get isLastMessage =>
chatController?.initialMessageList.last.id == widget.message.id;

bool get isFirstInGroup => widget.isFirstInGroup;

bool get isLastInGroup => widget.isLastInGroup;

FeatureActiveConfig? featureActiveConfig;
ChatController? chatController;
ChatUser? currentUser;
Expand Down Expand Up @@ -123,7 +143,8 @@ class _ChatBubbleWidgetState extends State<ChatBubbleWidget> {
final chatBubbleConfig = chatListConfig.chatBubbleConfig;
return Container(
padding: chatBubbleConfig?.padding ?? const EdgeInsets.only(left: 5.0),
margin: chatBubbleConfig?.margin ?? const EdgeInsets.only(bottom: 10),
margin: chatBubbleConfig?.margin ??
EdgeInsets.only(bottom: isLastInGroup ? 10 : 2),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment:
Expand All @@ -132,14 +153,35 @@ class _ChatBubbleWidgetState extends State<ChatBubbleWidget> {
children: [
if (!isMessageBySender &&
(featureActiveConfig?.enableOtherUserProfileAvatar ?? true))
profileCircle(messagedUser),
// IgnorePointer + ExcludeSemantics keep the avatar's layout space
// so bubbles stay aligned, but block tap/long-press callbacks and
// screen-reader nodes when the avatar is not visible.
ExcludeSemantics(
excluding: !isLastInGroup,
child: IgnorePointer(
ignoring: !isLastInGroup,
child: Opacity(
opacity: isLastInGroup ? 1.0 : 0.0,
child: profileCircle(messagedUser),
),
),
),
Comment on lines 154 to +168
Expanded(
child: _messagesWidgetColumn(messagedUser),
),
if (isMessageBySender) ...[getReceipt()],
if (isMessageBySender &&
(featureActiveConfig?.enableCurrentUserProfileAvatar ?? true))
profileCircle(messagedUser),
ExcludeSemantics(
excluding: !isLastInGroup,
child: IgnorePointer(
ignoring: !isLastInGroup,
child: Opacity(
opacity: isLastInGroup ? 1.0 : 0.0,
child: profileCircle(messagedUser),
),
),
),
Comment on lines 173 to +184
],
),
);
Expand Down Expand Up @@ -251,7 +293,8 @@ class _ChatBubbleWidgetState extends State<ChatBubbleWidget> {
children: [
if ((chatController?.otherUsers.isNotEmpty ?? false) &&
!isMessageBySender &&
(featureActiveConfig?.enableOtherUserName ?? true))
(featureActiveConfig?.enableOtherUserName ?? true) &&
isFirstInGroup)
Padding(
padding: chatListConfig
.chatBubbleConfig?.inComingChatBubbleConfig?.padding ??
Expand Down
31 changes: 31 additions & 0 deletions lib/src/widgets/chat_groupedlist_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -381,10 +381,30 @@ class _ChatGroupedListWidgetState extends State<ChatGroupedListWidget>
?.repliedMsgAutoScrollConfig
.enableScrollToRepliedMsg ??
false;

// Grouping is computed here in O(1) by checking only the
// two adjacent entries in the already-built messages list,
// rather than inside each bubble
//
// isFirstInGroup → show sender name at the visual top of the run
// isLastInGroup → show avatar + larger margin at the visual
// bottom of the run.
final groupingEnabled = featureActiveConfig
?.enableConsecutiveMessageGrouping ??
false;
final isFirstInGroup = !groupingEnabled ||
newIndex + 1 >= messages.length ||
!_sameGroup(message, messages[newIndex + 1]);
final isLastInGroup = !groupingEnabled ||
newIndex - 1 < 0 ||
!_sameGroup(message, messages[newIndex - 1]);

return ChatBubbleWidget(
key: messageKey,
message: message,
slideAnimation: _slideAnimation,
isFirstInGroup: isFirstInGroup,
isLastInGroup: isLastInGroup,
onLongPress: (yCoordinate, xCoordinate) =>
widget.onChatBubbleLongPress(
yCoordinate,
Expand Down Expand Up @@ -431,6 +451,17 @@ class _ChatGroupedListWidgetState extends State<ChatGroupedListWidget>
: elements.reversed.toList();
}

/// Returns `true` when [a] and [b] belong to the same consecutive sender
/// group, i.e. they share the same [Message.sentBy] value and were sent on
/// the same calendar day. Used to compute [ChatBubbleWidget.isFirstInGroup]
/// and [ChatBubbleWidget.isLastInGroup] in O(1) at the list-item level.
static bool _sameGroup(Message a, Message b) {
if (a.sentBy != b.sentBy) return false;
return a.createdAt.year == b.createdAt.year &&
a.createdAt.month == b.createdAt.month &&
a.createdAt.day == b.createdAt.day;
}

/// return DateTime by checking lastMatchedDate and message created DateTime
DateTime _groupBy(
Message message,
Expand Down
Loading