Repository keeps one live Timeline per room and re-yields on its onUpdate callback (fires for both live events AND requestHistory), not just onSync which never fires for pagination. loadMoreMessages now paginates that same instance; added canLoadMore. Timeline widget requests older history when scrolled within 400px of the oldest end, guarded against re-entry, with a top loading spinner. Verified: widget test drives a drag-to-top and asserts loadMoreMessages fires; live smoke test confirms the onUpdate delivery path (reused by history) still shows new messages after the refactor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
881 lines
27 KiB
Dart
881 lines
27 KiB
Dart
// Version: 1.7.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
|
// Full chat screen — timeline + input + typing indicators + read receipts
|
|
// + 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';
|
|
import '../../../shared/widgets/matrix_avatar.dart';
|
|
import '../../rooms/presentation/user_search_dialog.dart';
|
|
import '../data/chat_repository.dart';
|
|
import '../domain/message_model.dart';
|
|
import 'chat_controller.dart';
|
|
import 'message_bubble.dart';
|
|
import 'message_input.dart';
|
|
|
|
class ChatScreen extends ConsumerStatefulWidget {
|
|
const ChatScreen({super.key, required this.roomId});
|
|
|
|
final String roomId;
|
|
|
|
@override
|
|
ConsumerState<ChatScreen> createState() => _ChatScreenState();
|
|
}
|
|
|
|
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|
String? _replyToEventId;
|
|
String? _replyToSenderName;
|
|
String? _replyToBody;
|
|
|
|
String get _decodedRoomId => Uri.decodeComponent(widget.roomId);
|
|
|
|
void _showRoomOptions(BuildContext context, Room room) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (_) => _RoomOptionsSheet(room: room),
|
|
);
|
|
}
|
|
|
|
void _setReply(MessageModel message) {
|
|
setState(() {
|
|
_replyToEventId = message.eventId;
|
|
_replyToSenderName = message.senderDisplayName;
|
|
_replyToBody = message.body ?? '';
|
|
});
|
|
}
|
|
|
|
void _clearReply() {
|
|
setState(() {
|
|
_replyToEventId = null;
|
|
_replyToSenderName = null;
|
|
_replyToBody = null;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final client = ref.watch(matrixClientProvider);
|
|
final room = client.getRoomById(_decodedRoomId);
|
|
final roomName = room?.getLocalizedDisplayname() ?? 'Chat';
|
|
final roomAvatar = resolveMxcUrl(client, room?.avatar);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
titleSpacing: 0,
|
|
title: Row(
|
|
children: [
|
|
MatrixAvatar(name: roomName, avatarUrl: roomAvatar, radius: 18),
|
|
const SizedBox(width: 10),
|
|
Flexible(child: Text(roomName, overflow: TextOverflow.ellipsis)),
|
|
],
|
|
),
|
|
actions: [
|
|
// Voice call
|
|
IconButton(
|
|
icon: const Icon(Icons.call),
|
|
tooltip: 'Voice call',
|
|
onPressed: () => context.push(
|
|
'/calls/${Uri.encodeComponent(_decodedRoomId)}?video=0',
|
|
),
|
|
),
|
|
// Video call
|
|
IconButton(
|
|
icon: const Icon(Icons.videocam_outlined),
|
|
tooltip: 'Video call',
|
|
onPressed: () => context.push(
|
|
'/calls/${Uri.encodeComponent(_decodedRoomId)}?video=1',
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.more_vert),
|
|
tooltip: 'Room options',
|
|
onPressed: room != null
|
|
? () => _showRoomOptions(context, room)
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
Expanded(
|
|
child: _Timeline(roomId: _decodedRoomId, onReply: _setReply),
|
|
),
|
|
_TypingIndicator(roomId: _decodedRoomId),
|
|
_Input(
|
|
roomId: _decodedRoomId,
|
|
replyToEventId: _replyToEventId,
|
|
replyToSenderName: _replyToSenderName,
|
|
replyToBody: _replyToBody,
|
|
onCancelReply: _clearReply,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Timeline
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _Timeline extends ConsumerStatefulWidget {
|
|
const _Timeline({required this.roomId, required this.onReply});
|
|
|
|
final String roomId;
|
|
final void Function(MessageModel) onReply;
|
|
|
|
@override
|
|
ConsumerState<_Timeline> createState() => _TimelineState();
|
|
}
|
|
|
|
class _TimelineState extends ConsumerState<_Timeline> {
|
|
final _scrollController = ScrollController();
|
|
|
|
/// The list is reverse:true, so the newest message sits at offset 0.
|
|
/// Show the jump-to-latest button once the user scrolls up past this.
|
|
static const _showButtonThreshold = 400.0;
|
|
|
|
/// How close to the oldest (top of a reverse list) before we prefetch more.
|
|
static const _loadMoreThreshold = 400.0;
|
|
|
|
bool _showScrollToLatest = false;
|
|
bool _loadingMore = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_scrollController.addListener(_onScroll);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.removeListener(_onScroll);
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (!_scrollController.hasClients) return;
|
|
final pos = _scrollController.position;
|
|
|
|
final show = pos.pixels > _showButtonThreshold;
|
|
if (show != _showScrollToLatest) {
|
|
setState(() => _showScrollToLatest = show);
|
|
}
|
|
|
|
// Near the oldest end (top of a reverse list) → load older history.
|
|
if (pos.hasContentDimensions &&
|
|
pos.pixels >= pos.maxScrollExtent - _loadMoreThreshold) {
|
|
_loadOlder();
|
|
}
|
|
}
|
|
|
|
Future<void> _loadOlder() async {
|
|
if (_loadingMore) return;
|
|
final repo = ref.read(chatRepositoryProvider);
|
|
if (!repo.canLoadMore(widget.roomId)) return;
|
|
setState(() => _loadingMore = true);
|
|
try {
|
|
await repo.loadMoreMessages(widget.roomId);
|
|
} finally {
|
|
if (mounted) setState(() => _loadingMore = false);
|
|
}
|
|
}
|
|
|
|
void _scrollToLatest() {
|
|
if (!_scrollController.hasClients) return;
|
|
_scrollController.animateTo(
|
|
0,
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final timelineAsync = ref.watch(chatTimelineProvider(widget.roomId));
|
|
|
|
return timelineAsync.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (error, _) => Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text('Could not load messages: $error'),
|
|
),
|
|
),
|
|
data: (messages) {
|
|
if (messages.isEmpty) {
|
|
return Center(
|
|
child: Text(
|
|
'No messages yet. Say hello!',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurface.withAlpha(102),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Stack(
|
|
children: [
|
|
ListView.builder(
|
|
controller: _scrollController,
|
|
reverse: true,
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
itemCount: messages.length,
|
|
itemBuilder: (context, index) {
|
|
final message = messages[index];
|
|
// 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: widget.roomId,
|
|
onReply: () => widget.onReply(message),
|
|
isFirstInGroup: isFirstInGroup,
|
|
isLastInGroup: isLastInGroup,
|
|
);
|
|
|
|
if (!showDay) return bubble;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_DayChip(timestamp: message.timestamp),
|
|
bubble,
|
|
],
|
|
);
|
|
},
|
|
),
|
|
// Loading older history (top of a reverse list).
|
|
if (_loadingMore)
|
|
const Positioned(
|
|
top: 8,
|
|
left: 0,
|
|
right: 0,
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
right: 12,
|
|
bottom: 12,
|
|
child: AnimatedScale(
|
|
duration: const Duration(milliseconds: 150),
|
|
scale: _showScrollToLatest ? 1 : 0,
|
|
child: FloatingActionButton.small(
|
|
heroTag: 'scrollToLatest',
|
|
onPressed: _scrollToLatest,
|
|
tooltip: 'Jump to latest',
|
|
child: const Icon(Icons.arrow_downward),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _MessageWithGestures extends ConsumerWidget {
|
|
const _MessageWithGestures({
|
|
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,
|
|
isFirstInGroup: isFirstInGroup,
|
|
isLastInGroup: isLastInGroup,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showContextMenu(BuildContext context, WidgetRef ref) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
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();
|
|
},
|
|
onReact: () {
|
|
Navigator.pop(context);
|
|
_showEmojiPicker(context, ref);
|
|
},
|
|
onCopy: () {
|
|
Clipboard.setData(ClipboardData(text: message.body ?? ''));
|
|
Navigator.pop(context);
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('Message copied.')));
|
|
},
|
|
onEdit: message.isMine && message.body != null
|
|
? () {
|
|
Navigator.pop(context);
|
|
_showEditDialog(context, ref);
|
|
}
|
|
: null,
|
|
onDelete: message.isMine
|
|
? () async {
|
|
Navigator.pop(context);
|
|
await ref
|
|
.read(deleteMessageProvider.notifier)
|
|
.delete(roomId, message.eventId);
|
|
}
|
|
: null,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showEditDialog(BuildContext context, WidgetRef ref) {
|
|
final controller = TextEditingController(text: message.body ?? '');
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('Edit message'),
|
|
content: TextField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
maxLines: null,
|
|
decoration: const InputDecoration(border: OutlineInputBorder()),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () async {
|
|
final newText = controller.text.trim();
|
|
Navigator.of(ctx).pop();
|
|
if (newText.isEmpty || newText == message.body) return;
|
|
final error = await ref
|
|
.read(editMessageProvider.notifier)
|
|
.edit(roomId, message.eventId, newText);
|
|
if (error != null && context.mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('Edit failed: $error')));
|
|
}
|
|
},
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showEmojiPicker(BuildContext context, WidgetRef ref) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
builder: (_) => SizedBox(
|
|
height: 320,
|
|
child: EmojiPicker(
|
|
onEmojiSelected: (_, emoji) async {
|
|
Navigator.pop(context);
|
|
await ref
|
|
.read(sendReactionProvider.notifier)
|
|
.react(roomId, message.eventId, emoji.emoji);
|
|
},
|
|
config: const Config(
|
|
emojiViewConfig: EmojiViewConfig(columns: 8, emojiSizeMax: 28),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Room options sheet
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _RoomOptionsSheet extends ConsumerStatefulWidget {
|
|
const _RoomOptionsSheet({required this.room});
|
|
final Room room;
|
|
|
|
@override
|
|
ConsumerState<_RoomOptionsSheet> createState() => _RoomOptionsSheetState();
|
|
}
|
|
|
|
class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> {
|
|
bool _leaving = false;
|
|
|
|
Future<void> _leaveRoom() async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (_) => AlertDialog(
|
|
title: const Text('Leave room?'),
|
|
content: Text(
|
|
'You will leave "${widget.room.getLocalizedDisplayname()}". '
|
|
'You can rejoin if you have an invite.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Theme.of(context).colorScheme.error,
|
|
),
|
|
child: const Text('Leave'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed != true || !mounted) return;
|
|
|
|
setState(() => _leaving = true);
|
|
try {
|
|
await widget.room.leave();
|
|
if (mounted) {
|
|
Navigator.of(context).pop(); // close sheet
|
|
GoRouter.of(context).go('/rooms');
|
|
}
|
|
} on Exception catch (e) {
|
|
if (mounted) {
|
|
setState(() => _leaving = false);
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('Could not leave room: $e')));
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final client = ref.watch(matrixClientProvider);
|
|
final theme = Theme.of(context);
|
|
final room = widget.room;
|
|
final members = room.getParticipants();
|
|
final canInvite = room.canInvite;
|
|
|
|
return SafeArea(
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(context).size.height * 0.75,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Header
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 16, 4),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
room.getLocalizedDisplayname(),
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Members section
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
|
|
child: Text(
|
|
'Members (${members.length})',
|
|
style: theme.textTheme.labelMedium?.copyWith(
|
|
color: theme.colorScheme.primary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
Flexible(
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
itemCount: members.length,
|
|
itemBuilder: (context, index) {
|
|
final member = members[index];
|
|
final name = member.displayName ?? member.id.matrixLocalpart;
|
|
final avatarUrl = resolveMxcUrl(client, member.avatarUrl);
|
|
final isMe = member.id == client.userID;
|
|
|
|
return ListTile(
|
|
dense: true,
|
|
leading: MatrixAvatar(
|
|
name: name,
|
|
avatarUrl: avatarUrl,
|
|
radius: 16,
|
|
),
|
|
title: Text(
|
|
isMe ? '$name (you)' : name,
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
subtitle: Text(
|
|
member.id,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurface.withAlpha(120),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
const Divider(),
|
|
|
|
// Actions
|
|
if (canInvite)
|
|
ListTile(
|
|
leading: const Icon(Icons.person_add_outlined),
|
|
title: const Text('Invite someone'),
|
|
onTap: () {
|
|
Navigator.of(context).pop();
|
|
showUserSearchDialog(context);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: _leaving
|
|
? const SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Icon(Icons.exit_to_app, color: theme.colorScheme.error),
|
|
title: Text(
|
|
'Leave room',
|
|
style: TextStyle(color: theme.colorScheme.error),
|
|
),
|
|
enabled: !_leaving,
|
|
onTap: _leaveRoom,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MessageContextMenu extends StatelessWidget {
|
|
const _MessageContextMenu({
|
|
required this.message,
|
|
required this.roomId,
|
|
required this.onQuickReact,
|
|
required this.onReply,
|
|
required this.onReact,
|
|
required this.onCopy,
|
|
this.onEdit,
|
|
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;
|
|
final VoidCallback? onEdit;
|
|
final VoidCallback? onDelete;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SafeArea(
|
|
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'),
|
|
onTap: onReply,
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.add_reaction_outlined),
|
|
title: const Text('React'),
|
|
onTap: onReact,
|
|
),
|
|
if (message.body != null)
|
|
ListTile(
|
|
leading: const Icon(Icons.copy),
|
|
title: const Text('Copy text'),
|
|
onTap: onCopy,
|
|
),
|
|
if (onEdit != null)
|
|
ListTile(
|
|
leading: const Icon(Icons.edit_outlined),
|
|
title: const Text('Edit'),
|
|
onTap: onEdit,
|
|
),
|
|
if (onDelete != null)
|
|
ListTile(
|
|
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
|
title: const Text('Delete', style: TextStyle(color: Colors.red)),
|
|
onTap: onDelete,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Typing indicator
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _TypingIndicator extends ConsumerWidget {
|
|
const _TypingIndicator({required this.roomId});
|
|
|
|
final String roomId;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final client = ref.watch(matrixClientProvider);
|
|
final room = client.getRoomById(roomId);
|
|
if (room == null) return const SizedBox.shrink();
|
|
|
|
// typingUsers returns Users currently typing (excluding self).
|
|
final typing = room.typingUsers
|
|
.where((u) => u.id != client.userID)
|
|
.map((u) => u.displayName ?? u.id.matrixLocalpart)
|
|
.toList();
|
|
|
|
if (typing.isEmpty) return const SizedBox(height: 4);
|
|
|
|
final label = typing.length == 1
|
|
? '${typing.first} is typing…'
|
|
: '${typing.take(2).join(', ')} are typing…';
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurface.withAlpha(153),
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Input wrapper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _Input extends ConsumerWidget {
|
|
const _Input({
|
|
required this.roomId,
|
|
this.replyToEventId,
|
|
this.replyToSenderName,
|
|
this.replyToBody,
|
|
this.onCancelReply,
|
|
});
|
|
|
|
final String roomId;
|
|
final String? replyToEventId;
|
|
final String? replyToSenderName;
|
|
final String? replyToBody;
|
|
final VoidCallback? onCancelReply;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final isSending = ref.watch(sendMessageProvider);
|
|
final uploadState = ref.watch(uploadFileProvider);
|
|
final isUploading = uploadState == '';
|
|
|
|
return MessageInput(
|
|
isSending: isSending || isUploading,
|
|
replyTo: (replyToEventId != null && replyToSenderName != null)
|
|
? ReplyTo(
|
|
eventId: replyToEventId!,
|
|
senderDisplayName: replyToSenderName!,
|
|
body: replyToBody ?? '',
|
|
)
|
|
: null,
|
|
onCancelReply: onCancelReply,
|
|
onSend: (text) async {
|
|
final error = await ref
|
|
.read(sendMessageProvider.notifier)
|
|
.send(roomId, text, inReplyToEventId: replyToEventId);
|
|
onCancelReply?.call();
|
|
if (error != null && context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Failed to send: $error'),
|
|
backgroundColor: Theme.of(context).colorScheme.error,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
onAttach: (MatrixFile file) async {
|
|
await ref.read(uploadFileProvider.notifier).upload(roomId, file);
|
|
final err = ref.read(uploadFileProvider);
|
|
if (err != null && err.isNotEmpty && context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Upload failed: $err'),
|
|
backgroundColor: Theme.of(context).colorScheme.error,
|
|
),
|
|
);
|
|
ref.read(uploadFileProvider.notifier).clearError();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|