Files
Warp/apps/mobile/lib/screens/thread_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

278 lines
8.2 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import '../matrix/client.dart';
import '../matrix/timeline.dart';
import '../state.dart';
import '../widgets/composer.dart';
import '../widgets/message.dart';
/// A thread: its root, then its replies, with a composer that replies in
/// the thread.
///
/// The SDK has no thread timeline, so this keeps the room's own [Timeline]
/// (which receives new replies, their sending status, edits and reactions
/// like any event) and adds the older replies it doesn't have from
/// `/relations`.
class ThreadScreen extends ConsumerStatefulWidget {
const ThreadScreen({super.key, required this.roomId, required this.rootId});
final String roomId;
final String rootId;
static Route<void> route(String roomId, String rootId) => MaterialPageRoute(
settings: RouteSettings(name: 'thread/$roomId/$rootId'),
builder: (_) => ThreadScreen(roomId: roomId, rootId: rootId),
);
@override
ConsumerState<ThreadScreen> createState() => _ThreadScreenState();
}
class _ThreadScreenState extends ConsumerState<ThreadScreen> {
Timeline? _timeline;
Event? _root;
Object? _error;
/// Replies fetched from the server, by event ID.
final _fetched = <String, Event>{};
String? _nextBatch;
bool _loadingOlder = false;
bool _reachedStart = false;
Event? _replyTo;
final _scroll = ScrollController();
Room get _room => ref.read(clientProvider).getRoomById(widget.roomId)!;
@override
void initState() {
super.initState();
_scroll.addListener(_onScroll);
_load();
}
@override
void dispose() {
_timeline?.cancelSubscriptions();
_scroll.dispose();
super.dispose();
}
Future<void> _load() async {
final room = _room;
try {
final timeline = await room.getTimeline(
onUpdate: () {
if (mounted) setState(() {});
},
);
final root =
await timeline.getEventById(widget.rootId) ??
await room.getEventById(widget.rootId);
if (!mounted) {
timeline.cancelSubscriptions();
return;
}
setState(() {
_timeline = timeline;
_root = root;
});
await _loadOlder();
} catch (e) {
if (mounted) setState(() => _error = e);
}
}
Future<Event> _decrypt(Event event) async {
final encryption = event.room.client.encryption;
if (event.type != EventTypes.Encrypted || encryption == null) return event;
try {
return await encryption.decryptRoomEvent(event);
} catch (_) {
return event;
}
}
/// Fetches the next page of older replies.
Future<void> _loadOlder() async {
if (_loadingOlder || _reachedStart) return;
final room = _room;
setState(() => _loadingOlder = true);
try {
final response = await room.client.getRelatingEventsWithRelType(
room.id,
widget.rootId,
RelationshipTypes.thread,
from: _nextBatch,
dir: Direction.b,
limit: 30,
);
for (final m in response.chunk) {
final event = await _decrypt(Event.fromMatrixEvent(m, room));
_fetched[event.eventId] = event;
}
final root = _root;
if (root != null && root.type == EventTypes.Encrypted) {
_root = await _decrypt(root);
}
_nextBatch = response.nextBatch;
_reachedStart = response.nextBatch == null;
} catch (e) {
Logs().w('Could not load the thread', e);
_reachedStart = true;
} finally {
if (mounted) setState(() => _loadingOlder = false);
}
}
void _onScroll() {
if (!_scroll.hasClients) return;
final position = _scroll.position;
if (position.pixels > position.maxScrollExtent - 400) _loadOlder();
}
/// The replies, newest first: the room timeline's (live) and the fetched
/// ones it doesn't have.
List<Event> _replies() {
final timeline = _timeline;
final live = <String, Event>{
for (final e in timeline?.events ?? <Event>[])
if (e.relationshipType == RelationshipTypes.thread &&
e.relationshipEventId == widget.rootId)
e.eventId: e,
};
final all = [
...live.values,
for (final e in _fetched.values)
if (!live.containsKey(e.eventId)) e,
];
all.sort((a, b) {
// Pending messages are the newest.
if (!a.status.isSynced && b.status.isSynced) return -1;
if (a.status.isSynced && !b.status.isSynced) return 1;
return b.originServerTs.compareTo(a.originServerTs);
});
return all;
}
@override
Widget build(BuildContext context) {
ref.watch(syncProvider);
final room = ref.watch(clientProvider).getRoomById(widget.roomId);
final timeline = _timeline;
final root = _root;
if (room == null) return Scaffold(appBar: AppBar());
Widget body;
if (_error != null) {
body = Center(child: Text(describeError(_error!)));
} else if (timeline == null) {
body = const Center(child: CircularProgressIndicator());
} else {
final replies = _replies();
final entries = [...replies, ?root];
final items = projectTimeline([
for (final e in entries)
(
sender: e.senderId,
time: e.originServerTs,
kind: kindOf(e, inThread: true),
),
]);
body = ListView.builder(
controller: _scroll,
reverse: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length + 1,
itemBuilder: (context, i) {
if (i == items.length) {
return _loadingOlder
? const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
)
: root == null
? const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: Text(
"The thread's first message isn't available.",
),
),
)
: const SizedBox(height: 8);
}
final item = items[i];
return switch (item) {
DayItem() => const SizedBox(height: 8),
EventItem(:final index, :final kind)
when kind == EntryKind.notice =>
NoticeRow(event: entries[index]),
EventItem(:final index, :final firstInGroup, :final lastInGroup) =>
Column(
children: [
MessageRow(
key: ValueKey(entries[index].eventId),
event: entries[index],
timeline: timeline,
firstInGroup: firstInGroup,
lastInGroup: lastInGroup,
readByOthers: false,
onReply: (e) => setState(() => _replyTo = e),
),
if (entries[index].eventId == widget.rootId)
const _ThreadDivider(),
],
),
};
},
);
}
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Thread'),
Text(
room.getLocalizedDisplayname(),
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
body: Column(
children: [
Expanded(child: body),
if (room.membership == Membership.join && room.canSendDefaultMessages)
Composer(
room: room,
replyTo: _replyTo,
onCancelReply: () => setState(() => _replyTo = null),
target: SendTarget(
threadRootId: widget.rootId,
threadLastEventId: () =>
_replies()
.where((e) => e.status.isSynced)
.firstOrNull
?.eventId ??
widget.rootId,
),
),
],
),
);
}
}
class _ThreadDivider extends StatelessWidget {
const _ThreadDivider();
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Divider(),
);
}