Files
warp/apps/mobile/lib/screens/thread_list_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

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)),
);
},
);
},
),
),
);
}
}