Files
warp/apps/mobile/lib/screens/room_screen.dart
T
tajniak81andClaude Opus 5.5 4271d2ab17 Mobile: replies, reactions and threads
- Replies: Reply on a message's long press puts it above the composer;
  sent replies quote the message they answer (sender and first line),
  with the reply fallback left out of the body.
- Reactions: six quick ones and any other on long press, chips under the
  message with counts, the user's own highlighted; tapping a chip adds or
  takes back a reaction, a long press lists who reacted.
- Threads: Reply in thread opens a thread view (the root, then its
  replies, older ones fetched from /relations, new ones live from the room
  timeline) with a composer that sends into the thread. Thread replies
  are hidden from the main timeline; the root shows "N replies". Room info
  lists the room's threads (/threads).

The composer moved out of the room screen into widgets/composer.dart so
the room and thread views share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:00:57 +02:00

506 lines
14 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import '../matrix/client.dart';
import '../matrix/notifications.dart';
import '../matrix/timeline.dart';
import '../state.dart';
import '../theme.dart';
import '../util/format.dart';
import '../widgets/avatar.dart';
import '../widgets/composer.dart';
import '../widgets/message.dart';
import 'room_info_screen.dart';
import 'thread_screen.dart';
class RoomScreen extends ConsumerStatefulWidget {
const RoomScreen({super.key, required this.roomId});
final String roomId;
static Route<void> route(String roomId) => MaterialPageRoute(
settings: RouteSettings(name: 'room/$roomId'),
builder: (_) => RoomScreen(roomId: roomId),
);
@override
ConsumerState<RoomScreen> createState() => _RoomScreenState();
}
class _RoomScreenState extends ConsumerState<RoomScreen>
with WidgetsBindingObserver {
Timeline? _timeline;
Object? _loadError;
final _scroll = ScrollController();
bool _loadingHistory = false;
StreamSubscription? _syncSub;
/// The message the user is replying to.
Event? _replyTo;
Room? get _room => ref.read(clientProvider).getRoomById(widget.roomId);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
Notifications.activeRoomId = widget.roomId;
Notifications.clearRoom(widget.roomId);
_scroll.addListener(_onScroll);
_syncSub = ref.read(clientProvider).onSync.stream.listen((_) {
if (mounted) setState(() {});
});
_load();
}
Future<void> _load() async {
final room = _room;
if (room == null) return;
try {
final timeline = await room.getTimeline(
onUpdate: _onTimelineUpdate,
onNewEvent: _markRead,
);
if (!mounted) {
timeline.cancelSubscriptions();
return;
}
setState(() => _timeline = timeline);
_markRead();
// A short room may not fill the screen: fetch more until it does.
WidgetsBinding.instance.addPostFrameCallback((_) => _onScroll());
} catch (e) {
if (mounted) setState(() => _loadError = e);
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
if (Notifications.activeRoomId == widget.roomId) {
Notifications.activeRoomId = null;
}
_timeline?.cancelSubscriptions();
_syncSub?.cancel();
_scroll.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) _markRead();
}
void _onTimelineUpdate() {
if (mounted) setState(() {});
}
/// Sends a read receipt for the newest event, while the room is on screen
/// and the app is in front.
void _markRead() {
final timeline = _timeline;
final room = _room;
if (timeline == null || room == null) return;
if (WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed) {
return;
}
if (room.membership != Membership.join) return;
if (room.notificationCount == 0 &&
!room.markedUnread &&
room.receiptState.global.latestOwnReceipt?.eventId ==
timeline.events.firstOrNull?.eventId) {
return;
}
if (room.markedUnread) room.markUnread(false);
timeline.setReadMarker().catchError((Object e) {
Logs().w('Could not send a read receipt', e);
});
Notifications.clearRoom(room.id);
}
void _onScroll() {
final timeline = _timeline;
if (timeline == null || _loadingHistory || !timeline.canRequestHistory) {
return;
}
if (!_scroll.hasClients) return;
final position = _scroll.position;
if (position.pixels < position.maxScrollExtent - 600) return;
setState(() => _loadingHistory = true);
timeline
.requestHistory()
.catchError((Object e) => Logs().w('Could not load history', e))
.whenComplete(() {
if (mounted) setState(() => _loadingHistory = false);
});
}
@override
Widget build(BuildContext context) {
final room = _room;
if (room == null) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: Text("This room isn't available.")),
);
}
return Scaffold(
appBar: _RoomAppBar(room: room),
body: Column(
children: [
Expanded(child: _buildTimeline(room)),
if (room.membership == Membership.invite)
_InviteBar(room: room)
else if (room.membership == Membership.join)
room.canSendDefaultMessages
? Composer(
room: room,
replyTo: _replyTo,
onCancelReply: () => setState(() => _replyTo = null),
)
: const _ReadOnlyBar(),
],
),
);
}
Widget _buildTimeline(Room room) {
final timeline = _timeline;
if (_loadError != null) {
return Center(child: Text(describeError(_loadError!)));
}
if (timeline == null) {
return const Center(child: CircularProgressIndicator());
}
final events = timeline.events;
final items = projectTimeline([
for (final e in events)
(sender: e.senderId, time: e.originServerTs, kind: kindOf(e)),
]);
final readUpTo = _readByOthersIndex(events, room);
final typing = room.typingUsers
.where((u) => u.id != room.client.userID)
.toList();
return ListView.builder(
controller: _scroll,
reverse: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length + 2,
itemBuilder: (context, i) {
if (i == 0) return _TypingRow(users: typing);
if (i == items.length + 1) {
return _HistoryEnd(
loading: _loadingHistory || timeline.canRequestHistory,
room: room,
);
}
final item = items[i - 1];
return switch (item) {
DayItem(:final day) => _DaySeparator(day: day),
EventItem(:final index, :final kind) when kind == EntryKind.notice =>
NoticeRow(event: events[index]),
EventItem(:final index, :final firstInGroup, :final lastInGroup) =>
MessageRow(
key: ValueKey(events[index].eventId),
event: events[index],
timeline: timeline,
firstInGroup: firstInGroup,
lastInGroup: lastInGroup,
readByOthers: readUpTo != null && index >= readUpTo,
onReply: (e) => setState(() => _replyTo = e),
onOpenThread: (e) => Navigator.of(
context,
).push(ThreadScreen.route(room.id, e.eventId)),
),
};
},
);
}
/// The index (newest first) of the newest event someone else has read,
/// so the user's messages from there back say "Read".
static int? _readByOthersIndex(List<Event> events, Room room) {
final me = room.client.userID;
for (var i = 0; i < events.length; i++) {
if (events[i].receipts.any((r) => r.user.id != me)) return i;
}
return null;
}
}
class _RoomAppBar extends StatelessWidget implements PreferredSizeWidget {
const _RoomAppBar({required this.room});
final Room room;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
Widget build(BuildContext context) {
final c = context.warp;
final text = Theme.of(context).textTheme;
// A room fresh from /createRoom may have no summary yet.
final members =
room.summary.mJoinedMemberCount ?? room.getParticipants().length;
final subtitle = room.isDirectChat
? room.directChatMatrixID ?? ''
: members == 1
? '1 member'
: '$members members';
return AppBar(
titleSpacing: 0,
title: InkWell(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => RoomInfoScreen(roomId: room.id)),
),
child: Row(
children: [
Avatar(
name: room.getLocalizedDisplayname(),
seed: room.id,
mxc: room.avatar,
size: 36,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
room.getLocalizedDisplayname(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: text.titleMedium,
),
Row(
children: [
if (room.encrypted) ...[
Icon(Icons.lock_outline, size: 12, color: c.tealText),
const SizedBox(width: 3),
],
Flexible(
child: Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: text.labelSmall,
),
),
],
),
],
),
),
],
),
),
actions: [
IconButton(
tooltip: 'Room info',
icon: const Icon(Icons.info_outline),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => RoomInfoScreen(roomId: room.id)),
),
),
],
);
}
}
class _DaySeparator extends StatelessWidget {
const _DaySeparator({required this.day});
final DateTime day;
@override
Widget build(BuildContext context) {
final c = context.warp;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Row(
children: [
Expanded(child: Divider(color: c.line)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
formatDay(day),
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(color: c.inkMuted),
),
),
Expanded(child: Divider(color: c.line)),
],
),
);
}
}
class _HistoryEnd extends StatelessWidget {
const _HistoryEnd({required this.loading, required this.room});
final bool loading;
final Room room;
@override
Widget build(BuildContext context) {
if (loading) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
final text = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Column(
children: [
Avatar(
name: room.getLocalizedDisplayname(),
seed: room.id,
mxc: room.avatar,
size: 64,
),
const SizedBox(height: 12),
Text(
room.getLocalizedDisplayname(),
style: text.titleLarge,
textAlign: TextAlign.center,
),
if (room.topic.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
room.topic,
style: text.bodySmall,
textAlign: TextAlign.center,
),
],
const SizedBox(height: 8),
Text(
'This is the beginning of the conversation.',
style: text.bodySmall,
),
],
),
);
}
}
class _TypingRow extends StatelessWidget {
const _TypingRow({required this.users});
final List<User> users;
@override
Widget build(BuildContext context) {
if (users.isEmpty) return const SizedBox(height: 4);
final names = users.map((u) => u.calcDisplayname()).toList();
final who = switch (names.length) {
1 => '${names[0]} is typing…',
2 => '${names[0]} and ${names[1]} are typing…',
_ => 'Several people are typing…',
};
return Padding(
padding: const EdgeInsets.fromLTRB(60, 4, 16, 8),
child: Text(
who,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: context.warp.inkMuted,
fontStyle: FontStyle.italic,
),
),
);
}
}
class _InviteBar extends StatefulWidget {
const _InviteBar({required this.room});
final Room room;
@override
State<_InviteBar> createState() => _InviteBarState();
}
class _InviteBarState extends State<_InviteBar> {
bool _busy = false;
Future<void> _act(bool join) async {
setState(() => _busy = true);
try {
if (join) {
await widget.room.join();
} else {
await widget.room.leave();
if (mounted) Navigator.pop(context);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(describeError(e))));
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"You've been invited to this room.",
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _busy ? null : () => _act(false),
child: const Text('Decline'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: _busy ? null : () => _act(true),
child: const Text('Join'),
),
),
],
),
],
),
),
);
}
}
class _ReadOnlyBar extends StatelessWidget {
const _ReadOnlyBar();
@override
Widget build(BuildContext context) => SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
"You can't send messages in this room.",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
);
}