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>
366 lines
10 KiB
Dart
366 lines
10 KiB
Dart
// 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';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
import '../../../app/theme.dart';
|
|
import '../../../shared/widgets/matrix_avatar.dart';
|
|
import '../domain/message_model.dart';
|
|
|
|
class MessageBubble extends StatelessWidget {
|
|
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);
|
|
final isMine = message.isMine;
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
top: isFirstInGroup ? 8 : 1,
|
|
bottom: isLastInGroup ? 2 : 1,
|
|
left: isMine ? 48 : 8,
|
|
right: isMine ? 8 : 48,
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
mainAxisAlignment: isMine
|
|
? MainAxisAlignment.end
|
|
: MainAxisAlignment.start,
|
|
children: [
|
|
if (!isMine) ...[
|
|
// 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(
|
|
child: Column(
|
|
crossAxisAlignment: isMine
|
|
? CrossAxisAlignment.end
|
|
: CrossAxisAlignment.start,
|
|
children: [
|
|
if (!isMine && isFirstInGroup)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 4, bottom: 2),
|
|
child: Text(
|
|
message.senderDisplayName,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.primary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
_BubbleContent(
|
|
message: message,
|
|
isMine: isMine,
|
|
isLastInGroup: isLastInGroup,
|
|
),
|
|
if (message.reactions.isNotEmpty)
|
|
_ReactionsRow(reactions: message.reactions),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BubbleContent extends StatelessWidget {
|
|
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) {
|
|
final theme = Theme.of(context);
|
|
|
|
final bgColour = isMine
|
|
? M8Colours.selfBubble
|
|
: M8Colours.darkSurfaceVariant;
|
|
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),
|
|
decoration: BoxDecoration(
|
|
color: bgColour,
|
|
border: isMine
|
|
? null
|
|
: Border.all(color: theme.colorScheme.outline.withAlpha(31)),
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: const Radius.circular(16),
|
|
topRight: const Radius.circular(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(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
_MessageContentBody(message: message, textColour: textColour),
|
|
const SizedBox(height: 2),
|
|
_Timestamp(
|
|
timestamp: message.timestamp,
|
|
isEdited: message.isEdited,
|
|
textColour: textColour.withAlpha(153),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MessageContentBody extends StatelessWidget {
|
|
const _MessageContentBody({required this.message, required this.textColour});
|
|
|
|
final MessageModel message;
|
|
final Color textColour;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return switch (message.type) {
|
|
MessageType.text => Text(
|
|
message.body ?? '',
|
|
style: TextStyle(color: textColour),
|
|
),
|
|
MessageType.image => _ImageContent(message: message),
|
|
MessageType.file => _FileContent(
|
|
message: message,
|
|
textColour: textColour,
|
|
),
|
|
MessageType.redacted => Text(
|
|
'This message was deleted.',
|
|
style: TextStyle(
|
|
color: textColour.withAlpha(153),
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
MessageType.encrypted => Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.lock_outline, size: 14, color: textColour.withAlpha(153)),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
'Encrypted. Use Element to read',
|
|
style: TextStyle(
|
|
color: textColour.withAlpha(153),
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
_ => Text(
|
|
message.body ?? 'Unsupported message type',
|
|
style: TextStyle(
|
|
color: textColour.withAlpha(153),
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
};
|
|
}
|
|
}
|
|
|
|
class _ImageContent extends StatelessWidget {
|
|
const _ImageContent({required this.message});
|
|
|
|
final MessageModel message;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final url = message.mediaUrl;
|
|
if (url == null) return const Icon(Icons.image_not_supported);
|
|
|
|
return GestureDetector(
|
|
onTap: () => _openFullscreen(context, url, message.body),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: CachedNetworkImage(
|
|
imageUrl: url,
|
|
width: 240,
|
|
fit: BoxFit.cover,
|
|
placeholder: (_, __) => const SizedBox(
|
|
height: 160,
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
errorWidget: (_, __, ___) => const SizedBox(
|
|
height: 80,
|
|
child: Center(child: Icon(Icons.broken_image)),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openFullscreen(BuildContext context, String url, String? caption) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
fullscreenDialog: true,
|
|
builder: (_) => _ImageViewer(url: url, caption: caption),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ImageViewer extends StatelessWidget {
|
|
const _ImageViewer({required this.url, this.caption});
|
|
|
|
final String url;
|
|
final String? caption;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.black,
|
|
foregroundColor: Colors.white,
|
|
title: caption != null
|
|
? Text(
|
|
caption!,
|
|
style: const TextStyle(fontSize: 14),
|
|
overflow: TextOverflow.ellipsis,
|
|
)
|
|
: null,
|
|
),
|
|
body: Center(
|
|
child: InteractiveViewer(
|
|
minScale: 0.5,
|
|
maxScale: 4,
|
|
child: CachedNetworkImage(
|
|
imageUrl: url,
|
|
fit: BoxFit.contain,
|
|
placeholder: (_, __) =>
|
|
const CircularProgressIndicator(color: Colors.white),
|
|
errorWidget: (_, __, ___) =>
|
|
const Icon(Icons.broken_image, color: Colors.white, size: 64),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FileContent extends StatelessWidget {
|
|
const _FileContent({required this.message, required this.textColour});
|
|
|
|
final MessageModel message;
|
|
final Color textColour;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.attach_file, color: textColour, size: 18),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
message.body ?? 'File',
|
|
style: TextStyle(color: textColour),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Timestamp extends StatelessWidget {
|
|
const _Timestamp({
|
|
required this.timestamp,
|
|
required this.isEdited,
|
|
required this.textColour,
|
|
});
|
|
|
|
static final DateFormat _timeFormat = DateFormat('HH:mm');
|
|
|
|
final DateTime timestamp;
|
|
final bool isEdited;
|
|
final Color textColour;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final formatted = _timeFormat.format(timestamp.toLocal());
|
|
final label = isEdited ? '$formatted (edited)' : formatted;
|
|
|
|
return Text(label, style: TextStyle(fontSize: 10, color: textColour));
|
|
}
|
|
}
|
|
|
|
class _ReactionsRow extends StatelessWidget {
|
|
const _ReactionsRow({required this.reactions});
|
|
|
|
final Map<String, List<String>> reactions;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Wrap(
|
|
spacing: 4,
|
|
children: reactions.entries.map((entry) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: theme.colorScheme.outline.withAlpha(51),
|
|
),
|
|
),
|
|
child: Text(
|
|
'${entry.key} ${entry.value.length}',
|
|
style: const TextStyle(fontSize: 12),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
}
|