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:
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user