- 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>
126 lines
4.3 KiB
Dart
126 lines
4.3 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
|
|
import '../matrix/client.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`).
|
|
class ThreadListScreen extends ConsumerStatefulWidget {
|
|
const ThreadListScreen({super.key, required this.roomId});
|
|
final String roomId;
|
|
|
|
@override
|
|
ConsumerState<ThreadListScreen> createState() => _ThreadListScreenState();
|
|
}
|
|
|
|
class _ThreadListScreenState extends ConsumerState<ThreadListScreen> {
|
|
late Future<List<Event>> _threads = _load();
|
|
|
|
Future<List<Event>> _load() async {
|
|
final client = ref.read(clientProvider);
|
|
final room = client.getRoomById(widget.roomId)!;
|
|
final response = await client.getThreadRoots(room.id, limit: 50);
|
|
final roots = <Event>[];
|
|
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) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Threads')),
|
|
body: RefreshIndicator(
|
|
onRefresh: () async {
|
|
setState(() => _threads = _load());
|
|
await _threads;
|
|
},
|
|
child: FutureBuilder<List<Event>>(
|
|
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<String, Object?>('m.relations')
|
|
?.tryGetMap<String, Object?>(RelationshipTypes.thread);
|
|
final count = summary?.tryGet<int>('count') ?? 0;
|
|
final latestTs = summary
|
|
?.tryGetMap<String, Object?>('latest_event')
|
|
?.tryGet<int>('origin_server_ts');
|
|
final when = latestTs == null
|
|
? root.originServerTs
|
|
: DateTime.fromMillisecondsSinceEpoch(latestTs);
|
|
return ListTile(
|
|
leading: Avatar(
|
|
name: sender.calcDisplayname(),
|
|
seed: sender.id,
|
|
mxc: sender.avatarUrl,
|
|
),
|
|
title: Text(
|
|
quoteText(root),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
subtitle: Text(
|
|
'${sender.calcDisplayname()} · '
|
|
'${count == 1 ? '1 reply' : '$count replies'} · '
|
|
'${formatListTime(when)}',
|
|
),
|
|
onTap: () => Navigator.of(
|
|
context,
|
|
).push(ThreadScreen.route(widget.roomId, root.eventId)),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|