import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:matrix/matrix.dart'; import '../matrix/client.dart'; import '../matrix/thread_receipts.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`. /// /// While it is on screen and the app in front, it marks the thread read /// with a receipt for the thread alone; "Read" under your replies comes /// from the others' receipts in the thread. class ThreadScreen extends ConsumerStatefulWidget { const ThreadScreen({super.key, required this.roomId, required this.rootId}); final String roomId; final String rootId; static Route route(String roomId, String rootId) => MaterialPageRoute( settings: RouteSettings(name: 'thread/$roomId/$rootId'), builder: (_) => ThreadScreen(roomId: roomId, rootId: rootId), ); @override ConsumerState createState() => _ThreadScreenState(); } class _ThreadScreenState extends ConsumerState with WidgetsBindingObserver { Timeline? _timeline; Event? _root; Object? _error; /// Replies fetched from the server, by event ID. final _fetched = {}; String? _nextBatch; bool _loadingOlder = false; bool _reachedStart = false; Event? _replyTo; final _scroll = ScrollController(); /// The reply we last sent a receipt for, until the server echoes it. String? _receiptSent; Room get _room => ref.read(clientProvider).getRoomById(widget.roomId)!; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _scroll.addListener(_onScroll); _load(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _timeline?.cancelSubscriptions(); _scroll.dispose(); super.dispose(); } Future _load() async { final room = _room; try { final timeline = await room.getTimeline( onUpdate: () { if (mounted) setState(() {}); _markRead(); }, ); final root = await timeline.getEventById(widget.rootId) ?? await room.getEventById(widget.rootId); if (!mounted) { timeline.cancelSubscriptions(); return; } setState(() { _timeline = timeline; _root = root; }); await _loadOlder(); _markRead(); } catch (e) { if (mounted) setState(() => _error = e); } } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) _markRead(); } /// Sends a receipt in the thread for its newest reply, while the thread /// is on screen and the app is in front. void _markRead() { if (!mounted || _timeline == null) return; if (WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed) { return; } final room = _room; if (room.membership != Membership.join) return; final newest = _replies().where((e) => e.status.isSynced).firstOrNull; if (newest == null || newest.eventId == _receiptSent) return; if (ownThreadReceipt(room, widget.rootId)?.eventId == newest.eventId) { return; } _receiptSent = newest.eventId; markThreadRead(room, widget.rootId, newest.eventId).catchError((Object e) { Logs().w('Could not send a thread read receipt', e); _receiptSent = null; }); } Future _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 _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 _replies() { final timeline = _timeline; final live = { for (final e in timeline?.events ?? []) 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 readUpTo = _readByOthersIndex(entries, room, widget.rootId); 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: readUpTo != null && index >= readUpTo, 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, ), ), ], ), ); } } /// The index (newest first) of the newest entry someone else has read, /// by their receipt in the thread or an unthreaded one, so your replies /// from there back say "Read". int? _readByOthersIndex(List entries, Room room, String rootId) { for (var i = 0; i < entries.length; i++) { final e = entries[i]; if (!e.status.isSynced) continue; if (threadReaders(room, rootId, e.eventId).isNotEmpty || e.receipts.any((r) => r.user.id != room.client.userID)) { return i; } } return null; } class _ThreadDivider extends StatelessWidget { const _ThreadDivider(); @override Widget build(BuildContext context) => const Padding( padding: EdgeInsets.fromLTRB(16, 8, 16, 4), child: Divider(), ); }