diff --git a/help4bis-claude-notes/decisions.md b/help4bis-claude-notes/decisions.md index 0001ace..3a4d62a 100644 --- a/help4bis-claude-notes/decisions.md +++ b/help4bis-claude-notes/decisions.md @@ -106,3 +106,11 @@ - **Deferred (documented in spec):** B1 message grouping, B2 day separators, B4 quick-reaction row, B5 panic hold-ring, B6 scroll-to-latest FAB, C1 swipe-to-reply (iOS PWA back-gesture risk), C2 group sender prefix (needs RoomModel field), C3 demote Help nav. - **Verified:** headless Chromium at 390x844 on production build — gradient login, distinct avatar colours, compose FAB, AA bubble contrast all confirmed. - **Status:** built + deployed app2.m8chat.au v1.8.0+12 (backup pre180 tarball). Analyze clean, tests pass. + +### 2026-07-04 08:10 — Deferred UI items built (v1.9.0+13) +- **B1 message grouping:** MessageBubble gains optional isFirstInGroup/isLastInGroup (defaulted true so callers keep compiling). _Timeline computes them from reverse-index neighbours (older=index+1, newer=index-1) + 5-min gap. Sender name only on first-of-run, avatar only on last-of-run (32px gutter holds space otherwise), tail corner only on last-of-run, tight 1px padding mid-run. +- **B2 day separators:** _DayChip (Today/Yesterday/d MMM, toLocal AEST) rendered in a Column above the first message of each local day. Column wraps the gesture widget so long-press stays intact. +- **B4 quick-reaction row:** _MessageContextMenu gains a top row of six presets (👍❤️😂😮😢🙏, 44dp each) + "+" opening the existing EmojiPicker, above Reply. Uses existing sendReaction provider. +- **Verified in headless Chromium on production build:** grouping renders (Sam's 3-run collapsed to tight blue bubbles; Alex's 2-run shows name once + avatar on last bubble only, amber deterministic disc), "Today" chip renders, quick-react row renders with all six presets + picker button, tapping ❤️ sent a real m.reaction (confirmed via server /messages). Long-press context menu still fires (all of Reply/React/Copy/Edit/Delete intact). +- **Note:** cross-day separators are logic-only verified (can't backdate events without an appservice); the same-day "Today" chip confirms the render path. +- **Status:** built + deployed app2.m8chat.au v1.9.0+13 (backup pre190 tarball). Analyze clean, format clean, tests pass. diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 79b1cb8..973211b 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -11,7 +11,7 @@ abstract final class AppConfig { 'turns:matrix.m8chat.au:5349', ]; static const String appName = 'M8Chat'; - static const String appVersion = '1.8.0'; + static const String appVersion = '1.9.0'; // Jitsi conferencing static const String jitsiDomain = 'conf.m8chat.au'; diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 0080aba..d4d8caa 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -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'), diff --git a/lib/features/chat/presentation/message_bubble.dart b/lib/features/chat/presentation/message_bubble.dart index 63b92db..dbb3456 100644 --- a/lib/features/chat/presentation/message_bubble.dart +++ b/lib/features/chat/presentation/message_bubble.dart @@ -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), ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index ce8f280..f9ca2db 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: m8chat_app description: "M8Chat — Matrix chat client for Android, iOS, and Web." publish_to: 'none' -version: 1.8.0+12 +version: 1.9.0+13 environment: sdk: '>=3.11.0 <4.0.0'