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>
330 lines
11 KiB
Dart
330 lines
11 KiB
Dart
import 'package:flutter/material.dart' hide Visibility;
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
|
|
import '../matrix/client.dart';
|
|
import '../state.dart';
|
|
import '../theme.dart';
|
|
import '../widgets/avatar.dart';
|
|
import 'room_screen.dart';
|
|
|
|
/// The rooms of a space, the user's own and the ones they could join
|
|
/// (`/hierarchy`): join them, open them, and for those who may, add rooms.
|
|
class SpaceScreen extends ConsumerStatefulWidget {
|
|
const SpaceScreen({super.key, required this.spaceId});
|
|
final String spaceId;
|
|
|
|
@override
|
|
ConsumerState<SpaceScreen> createState() => _SpaceScreenState();
|
|
}
|
|
|
|
class _SpaceScreenState extends ConsumerState<SpaceScreen> {
|
|
late Future<List<SpaceRoomsChunk$2>> _rooms = _load();
|
|
final _joining = <String>{};
|
|
|
|
Future<List<SpaceRoomsChunk$2>> _load() async {
|
|
final client = ref.read(clientProvider);
|
|
// The topic and other non-essential state are loaded on demand.
|
|
await client.getRoomById(widget.spaceId)?.postLoad();
|
|
final rooms = <SpaceRoomsChunk$2>[];
|
|
String? from;
|
|
// A few pages at most: a space with hundreds of rooms is browsed by
|
|
// scrolling the first ones, not by fetching everything up front.
|
|
for (var page = 0; page < 5; page++) {
|
|
final GetSpaceHierarchyResponse response;
|
|
try {
|
|
response = await client.getSpaceHierarchy(
|
|
widget.spaceId,
|
|
maxDepth: 2,
|
|
limit: 50,
|
|
from: from,
|
|
);
|
|
} catch (e, s) {
|
|
Logs().e('Could not load the space hierarchy', e, s);
|
|
rethrow;
|
|
}
|
|
rooms.addAll(response.rooms.where((r) => r.roomId != widget.spaceId));
|
|
from = response.nextBatch;
|
|
if (from == null) break;
|
|
}
|
|
return rooms;
|
|
}
|
|
|
|
void _reload() => setState(() {
|
|
_rooms = _load();
|
|
});
|
|
|
|
void _snack(Object e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(describeError(e))));
|
|
}
|
|
|
|
Future<void> _join(SpaceRoomsChunk$2 chunk, Room space) async {
|
|
final client = ref.read(clientProvider);
|
|
setState(() => _joining.add(chunk.roomId));
|
|
try {
|
|
final via = space.spaceChildren
|
|
.where((c) => c.roomId == chunk.roomId)
|
|
.firstOrNull
|
|
?.via;
|
|
await client.joinRoom(chunk.roomId, via: via);
|
|
if (client.getRoomById(chunk.roomId)?.membership != Membership.join) {
|
|
await client.waitForRoomInSync(chunk.roomId, join: true);
|
|
}
|
|
_reload();
|
|
} catch (e) {
|
|
_snack(e);
|
|
} finally {
|
|
if (mounted) setState(() => _joining.remove(chunk.roomId));
|
|
}
|
|
}
|
|
|
|
Future<void> _addExisting(Room space) async {
|
|
final client = ref.read(clientProvider);
|
|
final inSpace = space.spaceChildren.map((c) => c.roomId).toSet();
|
|
final candidates = client.rooms
|
|
.where(
|
|
(r) =>
|
|
r.membership == Membership.join &&
|
|
r.id != space.id &&
|
|
!inSpace.contains(r.id),
|
|
)
|
|
.toList();
|
|
final picked = await showModalBottomSheet<Room>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (sheet) => DraggableScrollableSheet(
|
|
expand: false,
|
|
initialChildSize: 0.6,
|
|
builder: (context, scroll) => ListView(
|
|
controller: scroll,
|
|
children: [
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(24, 0, 24, 8),
|
|
child: Text('Add a room to this space'),
|
|
),
|
|
if (candidates.isEmpty)
|
|
const Padding(
|
|
padding: EdgeInsets.all(24),
|
|
child: Text('All your rooms are already in it.'),
|
|
),
|
|
for (final r in candidates)
|
|
ListTile(
|
|
leading: Avatar(
|
|
name: r.getLocalizedDisplayname(),
|
|
seed: r.id,
|
|
mxc: r.avatar,
|
|
),
|
|
title: Text(r.getLocalizedDisplayname()),
|
|
subtitle: r.isSpace ? const Text('Space') : null,
|
|
onTap: () => Navigator.pop(sheet, r),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
if (picked == null) return;
|
|
try {
|
|
await space.setSpaceChild(picked.id);
|
|
_reload();
|
|
} catch (e) {
|
|
_snack(e);
|
|
}
|
|
}
|
|
|
|
Future<void> _createInSpace(Room space) async {
|
|
final client = ref.read(clientProvider);
|
|
final controller = TextEditingController();
|
|
final name = await showDialog<String>(
|
|
context: context,
|
|
builder: (dialog) => AlertDialog(
|
|
title: const Text('New room in this space'),
|
|
content: TextField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
textCapitalization: TextCapitalization.sentences,
|
|
decoration: const InputDecoration(labelText: 'Name'),
|
|
onSubmitted: (v) => Navigator.pop(dialog, v),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialog),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(dialog, controller.text),
|
|
child: const Text('Create'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (name == null || name.trim().isEmpty) return;
|
|
try {
|
|
final roomId = await client.createGroupChat(
|
|
groupName: name.trim(),
|
|
enableEncryption: client.encryptionEnabled,
|
|
preset: CreateRoomPreset.privateChat,
|
|
);
|
|
await space.setSpaceChild(roomId);
|
|
_reload();
|
|
} catch (e) {
|
|
_snack(e);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
ref.watch(syncProvider);
|
|
final client = ref.watch(clientProvider);
|
|
final space = client.getRoomById(widget.spaceId);
|
|
if (space == null) return Scaffold(appBar: AppBar());
|
|
final canAdd = space.canChangeStateEvent(EventTypes.SpaceChild);
|
|
final text = Theme.of(context).textTheme;
|
|
final c = context.warp;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(space.getLocalizedDisplayname()),
|
|
actions: [
|
|
if (canAdd)
|
|
PopupMenuButton<String>(
|
|
icon: const Icon(Icons.add),
|
|
tooltip: 'Add a room',
|
|
onSelected: (v) =>
|
|
v == 'new' ? _createInSpace(space) : _addExisting(space),
|
|
itemBuilder: (_) => const [
|
|
PopupMenuItem(value: 'new', child: Text('New room')),
|
|
PopupMenuItem(value: 'existing', child: Text('Existing room')),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
body: RefreshIndicator(
|
|
onRefresh: () async {
|
|
_reload();
|
|
await _rooms;
|
|
},
|
|
child: FutureBuilder<List<SpaceRoomsChunk$2>>(
|
|
future: _rooms,
|
|
builder: (context, snapshot) {
|
|
final rooms = snapshot.data;
|
|
return ListView(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Row(
|
|
children: [
|
|
Avatar(
|
|
name: space.getLocalizedDisplayname(),
|
|
seed: space.id,
|
|
mxc: space.avatar,
|
|
size: 56,
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
space.getLocalizedDisplayname(),
|
|
style: text.titleLarge,
|
|
),
|
|
if (space.topic.isNotEmpty)
|
|
Text(space.topic, style: text.bodySmall),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(),
|
|
if (snapshot.hasError)
|
|
Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text(describeError(snapshot.error!)),
|
|
)
|
|
else if (rooms == null)
|
|
const Padding(
|
|
padding: EdgeInsets.all(32),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
)
|
|
else if (rooms.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Text(
|
|
canAdd
|
|
? 'No rooms yet. Add one with +.'
|
|
: 'No rooms in this space yet.',
|
|
textAlign: TextAlign.center,
|
|
),
|
|
)
|
|
else
|
|
for (final r in rooms)
|
|
Builder(
|
|
builder: (context) {
|
|
final local = client.getRoomById(r.roomId);
|
|
final joined = local?.membership == Membership.join;
|
|
final isSpace = r.roomType == 'm.space';
|
|
final name = r.name ?? r.canonicalAlias ?? r.roomId;
|
|
return ListTile(
|
|
leading: Avatar(
|
|
name: name,
|
|
seed: r.roomId,
|
|
mxc: r.avatarUrl,
|
|
),
|
|
title: Text(name),
|
|
subtitle: Text(
|
|
[
|
|
if (isSpace) 'Space',
|
|
'${r.numJoinedMembers} '
|
|
'${r.numJoinedMembers == 1 ? 'member' : 'members'}',
|
|
if (r.topic != null && r.topic!.isNotEmpty)
|
|
r.topic!,
|
|
].join(' · '),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
trailing: joined
|
|
? Text(
|
|
'Joined',
|
|
style: text.labelMedium?.copyWith(
|
|
color: c.tealText,
|
|
),
|
|
)
|
|
: _joining.contains(r.roomId)
|
|
? const SizedBox.square(
|
|
dimension: 24,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
),
|
|
)
|
|
: FilledButton(
|
|
onPressed: () => _join(r, space),
|
|
child: const Text('Join'),
|
|
),
|
|
onTap: !joined
|
|
? null
|
|
: isSpace
|
|
? () => Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) =>
|
|
SpaceScreen(spaceId: r.roomId),
|
|
),
|
|
)
|
|
: () => Navigator.of(
|
|
context,
|
|
).push(RoomScreen.route(r.roomId)),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|