feat(chat): message grouping, day separators, quick-reaction row (v1.9.0+13)

B1 grouping: same-sender runs within 5 min collapse to one name + one
  avatar (last-of-run), tight padding, tail corner only on last bubble
B2 day separators: Today/Yesterday/d MMM chip above each local day's
  first message, Column wraps the gesture widget (long-press preserved)
B4 quick reactions: six preset emoji + full-picker button atop the
  long-press menu, wired to the existing sendReaction provider

Verified end-to-end in headless Chromium on the production build;
tapping a preset sends a real m.reaction (confirmed server-side).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 08:10:11 +10:00
parent d2a2a1ab07
commit f55f8dc698
5 changed files with 213 additions and 42 deletions

View File

@@ -1,4 +1,4 @@
// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Message bubble widget. Handles text, images, files, redacted, replies.
import 'package:cached_network_image/cached_network_image.dart';
@@ -10,10 +10,21 @@ import '../../../shared/widgets/matrix_avatar.dart';
import '../domain/message_model.dart';
class MessageBubble extends StatelessWidget {
const MessageBubble({super.key, required this.message});
const MessageBubble({
super.key,
required this.message,
this.isFirstInGroup = true,
this.isLastInGroup = true,
});
final MessageModel message;
/// First message of a same-sender run: show the sender name, add top space.
final bool isFirstInGroup;
/// Last message of a same-sender run: show the avatar, keep the tail corner.
final bool isLastInGroup;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -21,8 +32,8 @@ class MessageBubble extends StatelessWidget {
return Padding(
padding: EdgeInsets.only(
top: 2,
bottom: 2,
top: isFirstInGroup ? 8 : 1,
bottom: isLastInGroup ? 2 : 1,
left: isMine ? 48 : 8,
right: isMine ? 8 : 48,
),
@@ -33,11 +44,16 @@ class MessageBubble extends StatelessWidget {
: MainAxisAlignment.start,
children: [
if (!isMine) ...[
MatrixAvatar(
name: message.senderDisplayName,
avatarUrl: message.senderAvatarUrl,
radius: 16,
),
// Only the last message of a run carries the avatar; earlier ones
// hold the gutter so the column stays aligned.
if (isLastInGroup)
MatrixAvatar(
name: message.senderDisplayName,
avatarUrl: message.senderAvatarUrl,
radius: 16,
)
else
const SizedBox(width: 32),
const SizedBox(width: 8),
],
Flexible(
@@ -46,7 +62,7 @@ class MessageBubble extends StatelessWidget {
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
if (!isMine)
if (!isMine && isFirstInGroup)
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 2),
child: Text(
@@ -57,7 +73,11 @@ class MessageBubble extends StatelessWidget {
),
),
),
_BubbleContent(message: message, isMine: isMine),
_BubbleContent(
message: message,
isMine: isMine,
isLastInGroup: isLastInGroup,
),
if (message.reactions.isNotEmpty)
_ReactionsRow(reactions: message.reactions),
],
@@ -70,10 +90,15 @@ class MessageBubble extends StatelessWidget {
}
class _BubbleContent extends StatelessWidget {
const _BubbleContent({required this.message, required this.isMine});
const _BubbleContent({
required this.message,
required this.isMine,
this.isLastInGroup = true,
});
final MessageModel message;
final bool isMine;
final bool isLastInGroup;
@override
Widget build(BuildContext context) {
@@ -85,6 +110,9 @@ class _BubbleContent extends StatelessWidget {
final textColour = isMine ? Colors.white : theme.colorScheme.onSurface;
final maxBubbleWidth =
(MediaQuery.sizeOf(context).width.clamp(0, 560) * 0.78).toDouble();
// Only the last bubble of a same-sender run gets the pointed tail corner;
// mid-run bubbles stay fully rounded so the run reads as one block.
final tail = isLastInGroup ? const Radius.circular(4) : null;
return Container(
constraints: BoxConstraints(maxWidth: maxBubbleWidth),
@@ -96,8 +124,12 @@ class _BubbleContent extends StatelessWidget {
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isMine ? 16 : 4),
bottomRight: Radius.circular(isMine ? 4 : 16),
bottomLeft: isMine
? const Radius.circular(16)
: (tail ?? const Radius.circular(16)),
bottomRight: isMine
? (tail ?? const Radius.circular(16))
: const Radius.circular(16),
),
),
child: Padding(
@@ -240,14 +272,10 @@ class _ImageViewer extends StatelessWidget {
child: CachedNetworkImage(
imageUrl: url,
fit: BoxFit.contain,
placeholder: (_, __) => const CircularProgressIndicator(
color: Colors.white,
),
errorWidget: (_, __, ___) => const Icon(
Icons.broken_image,
color: Colors.white,
size: 64,
),
placeholder: (_, __) =>
const CircularProgressIndicator(color: Colors.white),
errorWidget: (_, __, ___) =>
const Icon(Icons.broken_image, color: Colors.white, size: 64),
),
),
),