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 '../state.dart'; import '../util/format.dart'; import '../widgets/avatar.dart'; import '../widgets/message_extras.dart'; import 'thread_screen.dart'; /// A room's threads, the most recently active first (`/threads`); those /// with replies you haven't read are bold, with a dot. class ThreadListScreen extends ConsumerStatefulWidget { const ThreadListScreen({super.key, required this.roomId}); final String roomId; @override ConsumerState createState() => _ThreadListScreenState(); } class _ThreadListScreenState extends ConsumerState { late Future> _threads = _load(); Future> _load() async { final client = ref.read(clientProvider); final room = client.getRoomById(widget.roomId)!; final response = await client.getThreadRoots(room.id, limit: 50); final roots = []; for (final m in response.chunk) { var event = Event.fromMatrixEvent(m, room); final encryption = client.encryption; if (event.type == EventTypes.Encrypted && encryption != null) { try { event = await encryption.decryptRoomEvent(event); } catch (_) {} } roots.add(event); } return roots; } @override Widget build(BuildContext context) { // Receipts come with syncs: the unread marks follow them. ref.watch(syncProvider); return Scaffold( appBar: AppBar(title: const Text('Threads')), body: RefreshIndicator( onRefresh: () async { setState(() { _threads = _load(); }); await _threads; }, child: FutureBuilder>( future: _threads, builder: (context, snapshot) { if (snapshot.hasError) { return ListView( children: [ Padding( padding: const EdgeInsets.all(24), child: Text(describeError(snapshot.error!)), ), ], ); } final threads = snapshot.data; if (threads == null) { return const Center(child: CircularProgressIndicator()); } if (threads.isEmpty) { return ListView( children: const [ Padding( padding: EdgeInsets.all(32), child: Text( 'No threads yet. Long-press a message and choose ' 'Reply in thread to start one.', textAlign: TextAlign.center, ), ), ], ); } return ListView.separated( itemCount: threads.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, i) { final root = threads[i]; final sender = root.senderFromMemoryOrFallback; final summary = root.unsigned ?.tryGetMap('m.relations') ?.tryGetMap(RelationshipTypes.thread); final count = summary?.tryGet('count') ?? 0; final latestTs = summary ?.tryGetMap('latest_event') ?.tryGet('origin_server_ts'); final when = latestTs == null ? root.originServerTs : DateTime.fromMillisecondsSinceEpoch(latestTs); final unread = threadUnread(root); return ListTile( leading: Avatar( name: sender.calcDisplayname(), seed: sender.id, mxc: sender.avatarUrl, ), title: Text( quoteText(root), maxLines: 2, overflow: TextOverflow.ellipsis, style: unread ? const TextStyle(fontWeight: FontWeight.w700) : null, ), subtitle: Text( '${sender.calcDisplayname()} · ' '${count == 1 ? '1 reply' : '$count replies'} · ' '${formatListTime(when)}', ), trailing: unread ? Icon( Icons.circle, size: 10, color: Theme.of(context).colorScheme.primary, semanticLabel: 'Unread', ) : null, onTap: () => Navigator.of( context, ).push(ThreadScreen.route(widget.roomId, root.eventId)), ); }, ); }, ), ), ); } }