Mobile: - The room list's title picks a space: All chats, or one space, which narrows the list to its rooms (and its subspaces' rooms). - A space browser over /hierarchy: the rooms of a space and of its subspaces, joined or joinable, with Join; for those who may, adding an existing room or a new one to the space. - New space in the new-chat sheet; invites to spaces show with the other invites and open the space when joined. - Fixes several setState calls that were handed a Future (reloading the members, sessions, push distributor, threads), which Flutter rejects. Server: /hierarchy left out children_state for a room without children. The spec requires it on every room (an empty list), and SpaceRoom's omitempty dropped empty lists too, despite its comment. The famedly Dart SDK fails to parse such a response. The client API now wraps each room so the field is always written; the federation response, which wants it left out of the children it lists, is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
128 lines
4.3 KiB
Dart
128 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)),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|