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>
663 lines
21 KiB
Dart
663 lines
21 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
|
|
import '../util/format.dart';
|
|
import '../matrix/client.dart';
|
|
import '../state.dart';
|
|
import '../theme.dart';
|
|
import '../widgets/avatar.dart';
|
|
import '../widgets/new_chat_sheet.dart';
|
|
import 'encryption_screen.dart';
|
|
import 'room_screen.dart';
|
|
import 'space_screen.dart';
|
|
import 'settings_screen.dart';
|
|
|
|
enum _Filter { all, unread, people, rooms }
|
|
|
|
class HomeScreen extends ConsumerStatefulWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|
_Filter _filter = _Filter.all;
|
|
final _search = TextEditingController();
|
|
bool _searching = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_search.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
bool _matches(Room room, Set<String>? inSpace) {
|
|
if (room.isSpace && room.membership != Membership.invite) return false;
|
|
if (inSpace != null && !inSpace.contains(room.id)) return false;
|
|
final query = _search.text.trim().toLowerCase();
|
|
if (query.isNotEmpty &&
|
|
!room.getLocalizedDisplayname().toLowerCase().contains(query) &&
|
|
!room.canonicalAlias.toLowerCase().contains(query)) {
|
|
return false;
|
|
}
|
|
return switch (_filter) {
|
|
_Filter.all => true,
|
|
_Filter.unread => room.isUnreadOrInvited,
|
|
_Filter.people => room.isDirectChat,
|
|
_Filter.rooms => !room.isDirectChat,
|
|
};
|
|
}
|
|
|
|
Future<void> _pickSpace(Client client) async {
|
|
final spaces = client.rooms
|
|
.where((r) => r.isSpace && r.membership == Membership.join)
|
|
.toList();
|
|
final current = ref.read(selectedSpaceProvider);
|
|
// '' stands for "all chats"; null is a dismissed sheet.
|
|
final picked = await showModalBottomSheet<String>(
|
|
context: context,
|
|
builder: (sheet) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: Image.asset(
|
|
'assets/logo/warp-symbol.png',
|
|
width: 40,
|
|
height: 40,
|
|
),
|
|
title: const Text('All chats'),
|
|
selected: current == null,
|
|
onTap: () => Navigator.pop(sheet, ''),
|
|
),
|
|
for (final s in spaces)
|
|
ListTile(
|
|
leading: Avatar(
|
|
name: s.getLocalizedDisplayname(),
|
|
seed: s.id,
|
|
mxc: s.avatar,
|
|
),
|
|
title: Text(s.getLocalizedDisplayname()),
|
|
selected: current == s.id,
|
|
onTap: () => Navigator.pop(sheet, s.id),
|
|
),
|
|
if (spaces.isEmpty)
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(24, 8, 24, 16),
|
|
child: Text(
|
|
"You aren't in any spaces. Create one from the new chat "
|
|
'button, or join one by its address.',
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
if (picked == null) return;
|
|
ref
|
|
.read(selectedSpaceProvider.notifier)
|
|
.select(picked.isEmpty ? null : picked);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
ref.watch(syncProvider);
|
|
final client = ref.watch(clientProvider);
|
|
final offline = ref.watch(syncErrorProvider).value == true;
|
|
final spaceId = ref.watch(selectedSpaceProvider);
|
|
final space = spaceId == null ? null : client.getRoomById(spaceId);
|
|
final inSpace = space == null ? null : roomsInSpace(space);
|
|
final rooms = client.rooms.where((r) => _matches(r, inSpace)).toList();
|
|
final invites = rooms
|
|
.where((r) => r.membership == Membership.invite)
|
|
.toList();
|
|
final joined = rooms.where((r) => r.membership == Membership.join).toList();
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
titleSpacing: 16,
|
|
title: _searching
|
|
? TextField(
|
|
controller: _search,
|
|
autofocus: true,
|
|
onChanged: (_) => setState(() {}),
|
|
decoration: const InputDecoration(
|
|
hintText: 'Search rooms',
|
|
isDense: true,
|
|
),
|
|
)
|
|
: InkWell(
|
|
onTap: () => _pickSpace(client),
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Row(
|
|
children: [
|
|
space == null
|
|
? Image.asset(
|
|
'assets/logo/warp-symbol.png',
|
|
width: 28,
|
|
height: 28,
|
|
)
|
|
: Avatar(
|
|
name: space.getLocalizedDisplayname(),
|
|
seed: space.id,
|
|
mxc: space.avatar,
|
|
size: 28,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Flexible(
|
|
child: Text(
|
|
space?.getLocalizedDisplayname() ?? 'Chats',
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const Icon(Icons.arrow_drop_down),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
tooltip: _searching ? 'Close search' : 'Search',
|
|
icon: Icon(_searching ? Icons.close : Icons.search),
|
|
onPressed: () => setState(() {
|
|
_searching = !_searching;
|
|
if (!_searching) _search.clear();
|
|
}),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: IconButton(
|
|
tooltip: 'Settings',
|
|
icon: _OwnAvatar(client: client),
|
|
onPressed: () => Navigator.of(
|
|
context,
|
|
).push(MaterialPageRoute(builder: (_) => const SettingsScreen())),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
floatingActionButton: FloatingActionButton(
|
|
tooltip: 'New chat',
|
|
onPressed: () => showNewChatSheet(context),
|
|
child: const Icon(Icons.edit_outlined),
|
|
),
|
|
body: Column(
|
|
children: [
|
|
if (offline)
|
|
const _Banner(
|
|
icon: Icons.cloud_off_outlined,
|
|
text: "Can't reach the server. Retrying…",
|
|
),
|
|
_SecurityBanner(client: client),
|
|
SizedBox(
|
|
height: 52,
|
|
child: ListView(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
children: [
|
|
for (final f in _Filter.values)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
child: ChoiceChip(
|
|
label: Text(switch (f) {
|
|
_Filter.all => 'All',
|
|
_Filter.unread => 'Unread',
|
|
_Filter.people => 'People',
|
|
_Filter.rooms => 'Rooms',
|
|
}),
|
|
selected: _filter == f,
|
|
showCheckmark: false,
|
|
onSelected: (_) => setState(() => _filter = f),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: rooms.isEmpty && space == null
|
|
? _Empty(
|
|
loading: client.prevBatch == null,
|
|
filtered: _filter != _Filter.all || _search.text.isNotEmpty,
|
|
)
|
|
: ListView(
|
|
padding: const EdgeInsets.only(bottom: 88),
|
|
children: [
|
|
if (invites.isNotEmpty) ...[
|
|
const _SectionHeader('Invites'),
|
|
for (final room in invites) _InviteTile(room: room),
|
|
if (joined.isNotEmpty) const _SectionHeader('Chats'),
|
|
],
|
|
if (space != null)
|
|
ListTile(
|
|
leading: const Icon(Icons.travel_explore),
|
|
title: Text(
|
|
'Browse ${space.getLocalizedDisplayname()}',
|
|
),
|
|
subtitle: const Text('Rooms you can join'),
|
|
onTap: () => Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => SpaceScreen(spaceId: space.id),
|
|
),
|
|
),
|
|
),
|
|
for (final room in joined) RoomTile(room: room),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The rooms in [space]: its children, and the children of the spaces in
|
|
/// it, one level down.
|
|
Set<String> roomsInSpace(Room space) {
|
|
final client = space.client;
|
|
final ids = <String>{};
|
|
for (final child in space.spaceChildren) {
|
|
final id = child.roomId;
|
|
if (id == null) continue;
|
|
ids.add(id);
|
|
final sub = client.getRoomById(id);
|
|
if (sub != null && sub.isSpace && sub.membership == Membership.join) {
|
|
ids.addAll(sub.spaceChildren.map((c) => c.roomId).whereType<String>());
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
class _OwnAvatar extends StatelessWidget {
|
|
const _OwnAvatar({required this.client});
|
|
final Client client;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FutureBuilder<Profile>(
|
|
future: client.fetchOwnProfile(),
|
|
builder: (context, snapshot) {
|
|
final profile = snapshot.data;
|
|
return Avatar(
|
|
name: profile?.displayName ?? client.userID ?? '?',
|
|
seed: client.userID ?? '',
|
|
mxc: profile?.avatarUrl,
|
|
size: 32,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionHeader extends StatelessWidget {
|
|
const _SectionHeader(this.text);
|
|
final String text;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
|
child: Text(
|
|
text.toUpperCase(),
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
letterSpacing: 0.6,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _Banner extends StatelessWidget {
|
|
const _Banner({
|
|
required this.icon,
|
|
required this.text,
|
|
this.action,
|
|
this.onTap,
|
|
this.tone = _Tone.warning,
|
|
});
|
|
final IconData icon;
|
|
final String text;
|
|
final String? action;
|
|
final VoidCallback? onTap;
|
|
final _Tone tone;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.warp;
|
|
final (bg, fg) = switch (tone) {
|
|
_Tone.warning => (c.warningSoft, c.warning),
|
|
_Tone.info => (c.tealSoft, c.tealText),
|
|
};
|
|
return Material(
|
|
color: bg,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, size: 20, color: fg),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
text,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(color: fg),
|
|
),
|
|
),
|
|
if (action != null)
|
|
Text(
|
|
action!,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelLarge?.copyWith(color: fg),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum _Tone { warning, info }
|
|
|
|
/// Asks for encryption to be set up on an account that has none, or for
|
|
/// this session to be verified when the account has it and this session
|
|
/// can't yet read the older messages.
|
|
class _SecurityBanner extends StatelessWidget {
|
|
const _SecurityBanner({required this.client});
|
|
final Client client;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final encryption = client.encryption;
|
|
if (encryption == null || client.prevBatch == null) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
void open() => Navigator.of(
|
|
context,
|
|
).push(MaterialPageRoute(builder: (_) => const EncryptionScreen()));
|
|
if (!encryption.crossSigning.enabled) {
|
|
return _Banner(
|
|
icon: Icons.key_outlined,
|
|
text:
|
|
'Set up encryption to keep your messages readable on new '
|
|
'sessions.',
|
|
action: 'Set up',
|
|
tone: _Tone.info,
|
|
onTap: open,
|
|
);
|
|
}
|
|
if (client.isUnknownSession) {
|
|
return _Banner(
|
|
icon: Icons.verified_user_outlined,
|
|
text: 'Verify this session to read your encrypted messages.',
|
|
action: 'Verify',
|
|
onTap: open,
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
}
|
|
}
|
|
|
|
class _Empty extends StatelessWidget {
|
|
const _Empty({required this.loading, required this.filtered});
|
|
final bool loading;
|
|
final bool filtered;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (loading) return const Center(child: CircularProgressIndicator());
|
|
final text = Theme.of(context).textTheme;
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.forum_outlined,
|
|
size: 48,
|
|
color: context.warp.lineStrong,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
filtered ? 'Nothing here' : 'No chats yet',
|
|
style: text.titleMedium,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
filtered
|
|
? 'No rooms match.'
|
|
: 'Start a chat with someone, or create or join a room.',
|
|
textAlign: TextAlign.center,
|
|
style: text.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class RoomTile extends StatelessWidget {
|
|
const RoomTile({super.key, required this.room});
|
|
final Room room;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.warp;
|
|
final text = Theme.of(context).textTheme;
|
|
final last = room.lastEvent;
|
|
final unread = room.notificationCount;
|
|
final highlight = room.highlightCount > 0;
|
|
final bold = room.isUnread;
|
|
final preview = last == null
|
|
? ''
|
|
: last.calcLocalizedBodyFallback(
|
|
const MatrixDefaultLocalizations(),
|
|
withSenderNamePrefix: !room.isDirectChat,
|
|
hideReply: true,
|
|
hideEdit: true,
|
|
plaintextBody: false,
|
|
removeMarkdown: true,
|
|
);
|
|
|
|
return InkWell(
|
|
onTap: () => Navigator.of(context).push(RoomScreen.route(room.id)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
child: Row(
|
|
children: [
|
|
Avatar(
|
|
name: room.getLocalizedDisplayname(),
|
|
seed: room.id,
|
|
mxc: room.avatar,
|
|
size: 48,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
room.getLocalizedDisplayname(),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: text.titleSmall?.copyWith(
|
|
fontSize: 15,
|
|
fontWeight: bold
|
|
? FontWeight.w700
|
|
: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
if (last != null)
|
|
Text(
|
|
formatListTime(last.originServerTs),
|
|
style: text.labelSmall?.copyWith(
|
|
color: bold ? c.tealText : c.inkMuted,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 2),
|
|
Row(
|
|
children: [
|
|
if (room.encrypted) ...[
|
|
Icon(Icons.lock_outline, size: 13, color: c.inkMuted),
|
|
const SizedBox(width: 4),
|
|
],
|
|
Expanded(
|
|
child: Text(
|
|
preview,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: text.bodySmall?.copyWith(
|
|
color: bold ? c.ink : c.inkMuted,
|
|
),
|
|
),
|
|
),
|
|
if (unread > 0 || room.markedUnread)
|
|
Container(
|
|
margin: const EdgeInsets.only(left: 8),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 7,
|
|
vertical: 2,
|
|
),
|
|
constraints: const BoxConstraints(minWidth: 20),
|
|
decoration: BoxDecoration(
|
|
color: highlight ? c.danger : c.tealStrong,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Text(
|
|
unread > 99
|
|
? '99+'
|
|
: unread > 0
|
|
? '$unread'
|
|
: '',
|
|
textAlign: TextAlign.center,
|
|
style: text.labelMedium?.copyWith(
|
|
color: highlight ? Colors.white : c.onTeal,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InviteTile extends ConsumerStatefulWidget {
|
|
const _InviteTile({required this.room});
|
|
final Room room;
|
|
|
|
@override
|
|
ConsumerState<_InviteTile> createState() => _InviteTileState();
|
|
}
|
|
|
|
class _InviteTileState extends ConsumerState<_InviteTile> {
|
|
bool _busy = false;
|
|
|
|
Future<void> _act(Future<void> Function() action) async {
|
|
setState(() => _busy = true);
|
|
try {
|
|
await action();
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(describeError(e))));
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final room = widget.room;
|
|
final text = Theme.of(context).textTheme;
|
|
final inviter = room
|
|
.getState(EventTypes.RoomMember, room.client.userID!)
|
|
?.senderId;
|
|
final inviterName = inviter == null
|
|
? null
|
|
: room.unsafeGetUserFromMemoryOrFallback(inviter).calcDisplayname();
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
child: Row(
|
|
children: [
|
|
Avatar(
|
|
name: room.getLocalizedDisplayname(),
|
|
seed: room.id,
|
|
mxc: room.avatar,
|
|
size: 48,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
room.getLocalizedDisplayname(),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: text.titleSmall?.copyWith(fontSize: 15),
|
|
),
|
|
Text(
|
|
inviterName == null
|
|
? 'Invited you'
|
|
: '$inviterName invited you',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: text.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (_busy)
|
|
const SizedBox.square(
|
|
dimension: 24,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
else ...[
|
|
IconButton(
|
|
tooltip: 'Decline',
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => _act(room.leave),
|
|
),
|
|
IconButton.filled(
|
|
tooltip: 'Join',
|
|
icon: const Icon(Icons.check),
|
|
onPressed: () => _act(() async {
|
|
await room.join();
|
|
if (!context.mounted) return;
|
|
Navigator.of(context).push(
|
|
room.isSpace
|
|
? MaterialPageRoute(
|
|
builder: (_) => SpaceScreen(spaceId: room.id),
|
|
)
|
|
: RoomScreen.route(room.id),
|
|
);
|
|
}),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|