Files
warp/apps/mobile/lib/screens/home_screen.dart
T
tajniak81andClaude Opus 5.5 41f800ab0b Mobile: voice and video calls (MatrixRTC with LiveKit)
Calls the way the web client and Element Call do them, with the same
events, so the three can share a call. The famedly SDK's own group calls
use com.famedly.call.member events, which the others don't read, so this
is written against the matrix-js-sdk wire format instead (lib/matrix/
calls.dart):

- Membership: org.matrix.msc3401.call.member state, key
  _@user:server_DEVICE_m.call, with the legacy session fields the js-sdk
  sends (application m.call, call_id "", membershipID, expires,
  focus_active, foci_preferred). An empty delayed copy (MSC4140) is
  scheduled first and pushed back every 5 s; leaving sends the empty
  state and cancels it.
- Media: a LiveKit token from the JWT service (the call's, else the
  homeserver's rtc/transports or .well-known rtc_foci) for an OpenID
  token; livekit_client connects.
- Encrypted rooms: a random 16-byte key per device, sent to every other
  device in the call over encrypted to-device
  io.element.call.encryption_keys, keys from others applied per LiveKit
  identity (@user:server:DEVICE); per-participant keys, no ratcheting,
  HKDF, like the web client's livekit-client.
- Ringing: a call started in a room of two sends a ring notification
  (MSC4075); the other side gets an incoming-call card and an insistent
  ringtone notification (full screen on a locked phone), declines with
  MSC4310 or answers by joining. The caller gives up after a minute or on
  a decline.
- UI: voice and video buttons in the room header, a call screen (the
  other person large and you in the corner, or a grid; mute, camera, flip,
  leave), a Join banner and a room-list marker for calls going on.

Not tested end to end yet: it needs a LiveKit SFU and JWT service.

README: replies, reactions, threads, spaces, QR verification, sliding
sync and calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:43:19 +02:00

668 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 ConsumerWidget {
const RoomTile({super.key, required this.room});
final Room room;
@override
Widget build(BuildContext context, WidgetRef ref) {
final inCall = ref.watch(callsProvider).inCallCount(room.id) > 0;
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),
],
if (inCall) ...[
Icon(Icons.call, size: 14, color: c.tealText),
const SizedBox(width: 4),
],
Expanded(
child: Text(
inCall ? 'Call in progress' : 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),
);
}),
),
],
],
),
);
}
}