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,14 +1,17 @@
|
||||
// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||
// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||
// Full chat screen — timeline + input + typing indicators + read receipts
|
||||
// + long-press context menu (reply, react, copy, delete) + room options sheet.
|
||||
// + long-press context menu (quick reactions, reply, react, copy, edit,
|
||||
// delete) + day separators + same-sender message grouping + room options.
|
||||
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:matrix/matrix.dart' show MatrixFile, Room;
|
||||
|
||||
import '../../../app/theme.dart';
|
||||
import '../../../core/network/matrix_client.dart';
|
||||
import '../../../shared/utils/matrix_id.dart';
|
||||
import '../../../shared/utils/mxc_url.dart';
|
||||
@@ -161,10 +164,43 @@ class _Timeline extends ConsumerWidget {
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
return _MessageWithGestures(
|
||||
// List is reverse:true, so the visually-older neighbour is at
|
||||
// index+1 and the visually-newer neighbour is at index-1.
|
||||
final older = index < messages.length - 1
|
||||
? messages[index + 1]
|
||||
: null;
|
||||
final newer = index > 0 ? messages[index - 1] : null;
|
||||
|
||||
const groupGap = Duration(minutes: 5);
|
||||
final isFirstInGroup =
|
||||
older == null ||
|
||||
older.senderId != message.senderId ||
|
||||
message.timestamp.difference(older.timestamp) > groupGap;
|
||||
final isLastInGroup =
|
||||
newer == null ||
|
||||
newer.senderId != message.senderId ||
|
||||
newer.timestamp.difference(message.timestamp) > groupGap;
|
||||
|
||||
// A day chip sits above the first message of each local day.
|
||||
final showDay =
|
||||
older == null ||
|
||||
!_sameLocalDay(message.timestamp, older.timestamp);
|
||||
|
||||
final bubble = _MessageWithGestures(
|
||||
message: message,
|
||||
roomId: roomId,
|
||||
onReply: () => onReply(message),
|
||||
isFirstInGroup: isFirstInGroup,
|
||||
isLastInGroup: isLastInGroup,
|
||||
);
|
||||
|
||||
if (!showDay) return bubble;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_DayChip(timestamp: message.timestamp),
|
||||
bubble,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -173,6 +209,54 @@ class _Timeline extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// True when two timestamps fall on the same day in the local timezone.
|
||||
bool _sameLocalDay(DateTime a, DateTime b) {
|
||||
final la = a.toLocal();
|
||||
final lb = b.toLocal();
|
||||
return la.year == lb.year && la.month == lb.month && la.day == lb.day;
|
||||
}
|
||||
|
||||
/// Centred date pill shown above the first message of each local day.
|
||||
class _DayChip extends StatelessWidget {
|
||||
const _DayChip({required this.timestamp});
|
||||
|
||||
final DateTime timestamp;
|
||||
|
||||
String _label() {
|
||||
final now = DateTime.now().toLocal();
|
||||
final local = timestamp.toLocal();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final that = DateTime(local.year, local.month, local.day);
|
||||
final diff = today.difference(that).inDays;
|
||||
if (diff == 0) return 'Today';
|
||||
if (diff == 1) return 'Yesterday';
|
||||
if (local.year == now.year) return DateFormat('d MMM').format(local);
|
||||
return DateFormat('d MMM yyyy').format(local);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
_label(),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: M8Colours.subtleText,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Long-press context menu
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -182,17 +266,25 @@ class _MessageWithGestures extends ConsumerWidget {
|
||||
required this.message,
|
||||
required this.roomId,
|
||||
required this.onReply,
|
||||
this.isFirstInGroup = true,
|
||||
this.isLastInGroup = true,
|
||||
});
|
||||
|
||||
final MessageModel message;
|
||||
final String roomId;
|
||||
final VoidCallback onReply;
|
||||
final bool isFirstInGroup;
|
||||
final bool isLastInGroup;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return GestureDetector(
|
||||
onLongPress: () => _showContextMenu(context, ref),
|
||||
child: MessageBubble(message: message),
|
||||
child: MessageBubble(
|
||||
message: message,
|
||||
isFirstInGroup: isFirstInGroup,
|
||||
isLastInGroup: isLastInGroup,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,6 +294,12 @@ class _MessageWithGestures extends ConsumerWidget {
|
||||
builder: (_) => _MessageContextMenu(
|
||||
message: message,
|
||||
roomId: roomId,
|
||||
onQuickReact: (emoji) {
|
||||
Navigator.pop(context);
|
||||
ref
|
||||
.read(sendReactionProvider.notifier)
|
||||
.react(roomId, message.eventId, emoji);
|
||||
},
|
||||
onReply: () {
|
||||
Navigator.pop(context);
|
||||
onReply();
|
||||
@@ -261,9 +359,9 @@ class _MessageWithGestures extends ConsumerWidget {
|
||||
.read(editMessageProvider.notifier)
|
||||
.edit(roomId, message.eventId, newText);
|
||||
if (error != null && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Edit failed: $error')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Edit failed: $error')));
|
||||
}
|
||||
},
|
||||
child: const Text('Save'),
|
||||
@@ -346,9 +444,9 @@ class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> {
|
||||
} on Exception catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _leaving = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not leave room: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Could not leave room: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -409,10 +507,8 @@ class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> {
|
||||
itemCount: members.length,
|
||||
itemBuilder: (context, index) {
|
||||
final member = members[index];
|
||||
final name =
|
||||
member.displayName ?? member.id.matrixLocalpart;
|
||||
final avatarUrl =
|
||||
resolveMxcUrl(client, member.avatarUrl);
|
||||
final name = member.displayName ?? member.id.matrixLocalpart;
|
||||
final avatarUrl = resolveMxcUrl(client, member.avatarUrl);
|
||||
final isMe = member.id == client.userID;
|
||||
|
||||
return ListTile(
|
||||
@@ -456,10 +552,7 @@ class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> {
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
Icons.exit_to_app,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
: Icon(Icons.exit_to_app, color: theme.colorScheme.error),
|
||||
title: Text(
|
||||
'Leave room',
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
@@ -478,6 +571,7 @@ class _MessageContextMenu extends StatelessWidget {
|
||||
const _MessageContextMenu({
|
||||
required this.message,
|
||||
required this.roomId,
|
||||
required this.onQuickReact,
|
||||
required this.onReply,
|
||||
required this.onReact,
|
||||
required this.onCopy,
|
||||
@@ -485,8 +579,12 @@ class _MessageContextMenu extends StatelessWidget {
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
/// Six one-tap presets shown across the top of the menu.
|
||||
static const _quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
|
||||
|
||||
final MessageModel message;
|
||||
final String roomId;
|
||||
final void Function(String emoji) onQuickReact;
|
||||
final VoidCallback onReply;
|
||||
final VoidCallback onReact;
|
||||
final VoidCallback onCopy;
|
||||
@@ -499,6 +597,43 @@ class _MessageContextMenu extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Quick-reaction row: one-tap presets + open the full picker.
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
for (final emoji in _quickReactions)
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
onTap: () => onQuickReact(emoji),
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: Center(
|
||||
child: Text(
|
||||
emoji,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
onTap: onReact,
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: Icon(
|
||||
Icons.add,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.reply),
|
||||
title: const Text('Reply'),
|
||||
|
||||
Reference in New Issue
Block a user