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>
172 lines
5.9 KiB
Dart
172 lines
5.9 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 '../theme.dart';
|
|
import '../util/format.dart';
|
|
import '../widgets/verification_sheet.dart';
|
|
|
|
/// The account's sessions: which are verified, verifying one, and signing
|
|
/// one out.
|
|
class SessionsScreen extends ConsumerStatefulWidget {
|
|
const SessionsScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<SessionsScreen> createState() => _SessionsScreenState();
|
|
}
|
|
|
|
class _SessionsScreenState extends ConsumerState<SessionsScreen> {
|
|
late Future<List<Device>?> _devices;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_devices = ref.read(clientProvider).getDevices();
|
|
}
|
|
|
|
void _reload() => setState(() {
|
|
_devices = ref.read(clientProvider).getDevices();
|
|
});
|
|
|
|
void _snack(Object e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(describeError(e))));
|
|
}
|
|
|
|
Future<void> _signOut(Device device) async {
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialog) => AlertDialog(
|
|
title: const Text('Sign out this session?'),
|
|
content: Text(device.displayName ?? device.deviceId),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialog, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
style: FilledButton.styleFrom(backgroundColor: context.warp.danger),
|
|
onPressed: () => Navigator.pop(dialog, true),
|
|
child: const Text('Sign out'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (ok != true) return;
|
|
final client = ref.read(clientProvider);
|
|
try {
|
|
// Deleting a device needs the password: the request goes through the
|
|
// app's UIA handler.
|
|
await client.uiaRequestBackground(
|
|
(auth) => client.deleteDevice(device.deviceId, auth: auth),
|
|
);
|
|
_reload();
|
|
} catch (e) {
|
|
_snack(e);
|
|
}
|
|
}
|
|
|
|
Future<void> _verify(DeviceKeys keys) async {
|
|
try {
|
|
final request = await keys.startVerification();
|
|
if (mounted) await showVerificationSheet(context, request);
|
|
_reload();
|
|
} catch (e) {
|
|
_snack(e);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final client = ref.watch(clientProvider);
|
|
final c = context.warp;
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Sessions')),
|
|
body: FutureBuilder<List<Device>?>(
|
|
future: _devices,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.hasError) {
|
|
return Center(child: Text(describeError(snapshot.error!)));
|
|
}
|
|
final devices = snapshot.data;
|
|
if (devices == null) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
devices.sort((a, b) {
|
|
if (a.deviceId == client.deviceID) return -1;
|
|
if (b.deviceId == client.deviceID) return 1;
|
|
return (b.lastSeenTs ?? 0).compareTo(a.lastSeenTs ?? 0);
|
|
});
|
|
final ownKeys = client.userDeviceKeys[client.userID];
|
|
return RefreshIndicator(
|
|
onRefresh: () async => _reload(),
|
|
child: ListView(
|
|
children: [
|
|
for (final device in devices)
|
|
Builder(
|
|
builder: (context) {
|
|
final current = device.deviceId == client.deviceID;
|
|
final keys = ownKeys?.deviceKeys[device.deviceId];
|
|
final verified = keys?.verified == true;
|
|
final seen = device.lastSeenTs;
|
|
return ListTile(
|
|
leading: Icon(
|
|
verified
|
|
? Icons.verified_user
|
|
: Icons.gpp_maybe_outlined,
|
|
color: verified ? c.tealText : c.warning,
|
|
),
|
|
title: Text(
|
|
[
|
|
device.displayName ?? device.deviceId,
|
|
if (current) '(this session)',
|
|
].join(' '),
|
|
),
|
|
subtitle: Text(
|
|
[
|
|
device.deviceId,
|
|
verified ? 'verified' : 'not verified',
|
|
if (seen != null && !current)
|
|
'seen ${formatListTime(DateTime.fromMillisecondsSinceEpoch(seen))}',
|
|
].join(' · '),
|
|
),
|
|
trailing: current
|
|
? null
|
|
: PopupMenuButton<String>(
|
|
onSelected: (action) {
|
|
if (action == 'verify' && keys != null) {
|
|
_verify(keys);
|
|
} else if (action == 'signout') {
|
|
_signOut(device);
|
|
}
|
|
},
|
|
itemBuilder: (_) => [
|
|
if (!verified &&
|
|
keys != null &&
|
|
client.encryptionEnabled)
|
|
const PopupMenuItem(
|
|
value: 'verify',
|
|
child: Text('Verify'),
|
|
),
|
|
const PopupMenuItem(
|
|
value: 'signout',
|
|
child: Text('Sign out'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|