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>
423 lines
12 KiB
Dart
423 lines
12 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 '../screens/room_screen.dart';
|
|
import '../screens/space_screen.dart';
|
|
import '../state.dart';
|
|
|
|
/// Start a direct chat, create a room or join one.
|
|
Future<void> showNewChatSheet(BuildContext context) =>
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
builder: (sheet) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.person_add_alt_1_outlined),
|
|
title: const Text('Message someone'),
|
|
subtitle: const Text('A direct chat, end-to-end encrypted'),
|
|
onTap: () {
|
|
Navigator.pop(sheet);
|
|
_open(context, const _StartChatDialog());
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.group_add_outlined),
|
|
title: const Text('New room'),
|
|
subtitle: const Text('For a group or a topic'),
|
|
onTap: () {
|
|
Navigator.pop(sheet);
|
|
_open(context, const _CreateRoomDialog());
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.workspaces_outline),
|
|
title: const Text('New space'),
|
|
subtitle: const Text('A group of rooms'),
|
|
onTap: () {
|
|
Navigator.pop(sheet);
|
|
_open(context, const _CreateSpaceDialog());
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.login),
|
|
title: const Text('Join a room'),
|
|
subtitle: const Text('By its address, like #room:example.org'),
|
|
onTap: () {
|
|
Navigator.pop(sheet);
|
|
_open(context, const _JoinRoomDialog());
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
Future<void> _open(BuildContext context, Widget dialog) async {
|
|
final roomId = await showDialog<String>(
|
|
context: context,
|
|
builder: (_) => dialog,
|
|
);
|
|
if (roomId == null || !context.mounted) return;
|
|
final container = ProviderScope.containerOf(context, listen: false);
|
|
final room = container.read(clientProvider).getRoomById(roomId);
|
|
if (room != null && room.isSpace) {
|
|
// A space opens as the room list narrowed to it, with its browser on
|
|
// top.
|
|
container.read(selectedSpaceProvider.notifier).select(roomId);
|
|
Navigator.of(
|
|
context,
|
|
).push(MaterialPageRoute(builder: (_) => SpaceScreen(spaceId: roomId)));
|
|
} else {
|
|
Navigator.of(context).push(RoomScreen.route(roomId));
|
|
}
|
|
}
|
|
|
|
/// A dialog with one form and a busy state, returning a room ID.
|
|
class _FormDialog extends StatefulWidget {
|
|
const _FormDialog({
|
|
required this.title,
|
|
required this.action,
|
|
required this.fields,
|
|
required this.submit,
|
|
});
|
|
|
|
final String title;
|
|
final String action;
|
|
final List<Widget> fields;
|
|
final Future<String> Function() submit;
|
|
|
|
@override
|
|
State<_FormDialog> createState() => _FormDialogState();
|
|
}
|
|
|
|
class _FormDialogState extends State<_FormDialog> {
|
|
bool _busy = false;
|
|
String? _error;
|
|
|
|
Future<void> _go() async {
|
|
setState(() {
|
|
_busy = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
final roomId = await widget.submit();
|
|
if (mounted) Navigator.pop(context, roomId);
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_busy = false;
|
|
_error = describeError(e);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(widget.title),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
...widget.fields,
|
|
if (_error != null) ...[
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
_error!,
|
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: _busy ? null : () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: _busy ? null : _go,
|
|
child: _busy
|
|
? const SizedBox.square(
|
|
dimension: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Text(widget.action),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StartChatDialog extends ConsumerStatefulWidget {
|
|
const _StartChatDialog();
|
|
|
|
@override
|
|
ConsumerState<_StartChatDialog> createState() => _StartChatDialogState();
|
|
}
|
|
|
|
class _StartChatDialogState extends ConsumerState<_StartChatDialog> {
|
|
final _userId = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_userId.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final client = ref.watch(clientProvider);
|
|
return _FormDialog(
|
|
title: 'Message someone',
|
|
action: 'Start chat',
|
|
fields: [
|
|
TextField(
|
|
controller: _userId,
|
|
autofocus: true,
|
|
autocorrect: false,
|
|
keyboardType: TextInputType.emailAddress,
|
|
decoration: InputDecoration(
|
|
labelText: 'User ID',
|
|
hintText: '@alice:${client.userID?.domain ?? 'example.org'}',
|
|
),
|
|
),
|
|
],
|
|
submit: () async {
|
|
var id = _userId.text.trim();
|
|
if (!id.startsWith('@')) id = '@$id';
|
|
if (!id.contains(':')) id = '$id:${client.userID!.domain}';
|
|
if (!id.isValidMatrixIdStrict()) {
|
|
throw const FormatException("That isn't a user ID.");
|
|
}
|
|
if (id == client.userID) {
|
|
throw const FormatException("That's you.");
|
|
}
|
|
// Check they exist before creating a room for nobody.
|
|
await client.getUserProfile(id);
|
|
return client.startDirectChat(
|
|
id,
|
|
enableEncryption: client.encryptionEnabled,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CreateRoomDialog extends ConsumerStatefulWidget {
|
|
const _CreateRoomDialog();
|
|
|
|
@override
|
|
ConsumerState<_CreateRoomDialog> createState() => _CreateRoomDialogState();
|
|
}
|
|
|
|
class _CreateRoomDialogState extends ConsumerState<_CreateRoomDialog> {
|
|
final _name = TextEditingController();
|
|
final _topic = TextEditingController();
|
|
bool _public = false;
|
|
bool _encrypted = true;
|
|
|
|
@override
|
|
void dispose() {
|
|
_name.dispose();
|
|
_topic.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final client = ref.watch(clientProvider);
|
|
return _FormDialog(
|
|
title: 'New room',
|
|
action: 'Create',
|
|
fields: [
|
|
TextField(
|
|
controller: _name,
|
|
autofocus: true,
|
|
textCapitalization: TextCapitalization.sentences,
|
|
decoration: const InputDecoration(labelText: 'Name'),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _topic,
|
|
textCapitalization: TextCapitalization.sentences,
|
|
decoration: const InputDecoration(labelText: 'Topic (optional)'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
StatefulBuilder(
|
|
builder: (context, setLocal) => Column(
|
|
children: [
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('Public'),
|
|
subtitle: const Text('Anyone can find and join it'),
|
|
value: _public,
|
|
onChanged: (v) => setLocal(() => _public = v),
|
|
),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('End-to-end encrypted'),
|
|
subtitle: const Text("Can't be turned off later"),
|
|
value: _encrypted,
|
|
onChanged: client.encryptionEnabled
|
|
? (v) => setLocal(() => _encrypted = v)
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
submit: () async {
|
|
final name = _name.text.trim();
|
|
if (name.isEmpty) throw const FormatException('Give the room a name.');
|
|
final topic = _topic.text.trim();
|
|
final roomId = await client.createGroupChat(
|
|
groupName: name,
|
|
enableEncryption: _encrypted && client.encryptionEnabled,
|
|
preset: _public
|
|
? CreateRoomPreset.publicChat
|
|
: CreateRoomPreset.privateChat,
|
|
visibility: _public ? Visibility.public : Visibility.private,
|
|
initialState: [
|
|
if (topic.isNotEmpty)
|
|
StateEvent(type: EventTypes.RoomTopic, content: {'topic': topic}),
|
|
],
|
|
);
|
|
return roomId;
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _JoinRoomDialog extends ConsumerStatefulWidget {
|
|
const _JoinRoomDialog();
|
|
|
|
@override
|
|
ConsumerState<_JoinRoomDialog> createState() => _JoinRoomDialogState();
|
|
}
|
|
|
|
class _JoinRoomDialogState extends ConsumerState<_JoinRoomDialog> {
|
|
final _address = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_address.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final client = ref.watch(clientProvider);
|
|
return _FormDialog(
|
|
title: 'Join a room',
|
|
action: 'Join',
|
|
fields: [
|
|
TextField(
|
|
controller: _address,
|
|
autofocus: true,
|
|
autocorrect: false,
|
|
decoration: InputDecoration(
|
|
labelText: 'Room address',
|
|
hintText: '#room:${client.userID?.domain ?? 'example.org'}',
|
|
),
|
|
),
|
|
],
|
|
submit: () async {
|
|
var address = _address.text.trim();
|
|
if (address.isEmpty) {
|
|
throw const FormatException('Enter a room address.');
|
|
}
|
|
if (!address.startsWith('#') && !address.startsWith('!')) {
|
|
address = '#$address';
|
|
}
|
|
if (!address.contains(':')) {
|
|
address = '$address:${client.userID!.domain}';
|
|
}
|
|
final roomId = await client.joinRoom(address);
|
|
if (client.getRoomById(roomId)?.membership != Membership.join) {
|
|
await client.waitForRoomInSync(roomId, join: true);
|
|
}
|
|
return roomId;
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CreateSpaceDialog extends ConsumerStatefulWidget {
|
|
const _CreateSpaceDialog();
|
|
|
|
@override
|
|
ConsumerState<_CreateSpaceDialog> createState() => _CreateSpaceDialogState();
|
|
}
|
|
|
|
class _CreateSpaceDialogState extends ConsumerState<_CreateSpaceDialog> {
|
|
final _name = TextEditingController();
|
|
final _topic = TextEditingController();
|
|
bool _public = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_name.dispose();
|
|
_topic.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final client = ref.watch(clientProvider);
|
|
return _FormDialog(
|
|
title: 'New space',
|
|
action: 'Create',
|
|
fields: [
|
|
TextField(
|
|
controller: _name,
|
|
autofocus: true,
|
|
textCapitalization: TextCapitalization.sentences,
|
|
decoration: const InputDecoration(labelText: 'Name'),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _topic,
|
|
textCapitalization: TextCapitalization.sentences,
|
|
decoration: const InputDecoration(labelText: 'Topic (optional)'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
StatefulBuilder(
|
|
builder: (context, setLocal) => SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('Public'),
|
|
subtitle: const Text('Anyone can find and join it'),
|
|
value: _public,
|
|
onChanged: (v) => setLocal(() => _public = v),
|
|
),
|
|
),
|
|
],
|
|
submit: () async {
|
|
final name = _name.text.trim();
|
|
if (name.isEmpty) throw const FormatException('Give the space a name.');
|
|
final topic = _topic.text.trim();
|
|
final roomId = await client.createRoom(
|
|
name: name,
|
|
topic: topic.isEmpty ? null : topic,
|
|
creationContent: {'type': RoomCreationTypes.mSpace},
|
|
preset: _public
|
|
? CreateRoomPreset.publicChat
|
|
: CreateRoomPreset.privateChat,
|
|
visibility: _public ? Visibility.public : Visibility.private,
|
|
// A space's own timeline isn't for chatting: only admins post.
|
|
powerLevelContentOverride: {'events_default': 100},
|
|
);
|
|
if (client.getRoomById(roomId)?.membership != Membership.join) {
|
|
await client.waitForRoomInSync(roomId, join: true);
|
|
}
|
|
return roomId;
|
|
},
|
|
);
|
|
}
|
|
}
|