- 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>
278 lines
8.2 KiB
Dart
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(),
|
|
);
|
|
}
|