Files
Warp/apps/mobile/lib/screens/room_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

601 lines
17 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import '../matrix/client.dart';
import '../matrix/notifications.dart';
import '../matrix/timeline.dart';
import '../state.dart';
import '../theme.dart';
import '../util/format.dart';
import '../widgets/avatar.dart';
import '../widgets/composer.dart';
import '../widgets/message.dart';
import 'call_screen.dart';
import 'room_info_screen.dart';
import 'thread_screen.dart';
class RoomScreen extends ConsumerStatefulWidget {
const RoomScreen({super.key, required this.roomId});
final String roomId;
static Route<void> route(String roomId) => MaterialPageRoute(
settings: RouteSettings(name: 'room/$roomId'),
builder: (_) => RoomScreen(roomId: roomId),
);
@override
ConsumerState<RoomScreen> createState() => _RoomScreenState();
}
class _RoomScreenState extends ConsumerState<RoomScreen>
with WidgetsBindingObserver {
Timeline? _timeline;
Object? _loadError;
final _scroll = ScrollController();
bool _loadingHistory = false;
StreamSubscription? _syncSub;
/// The message the user is replying to.
Event? _replyTo;
Room? get _room => ref.read(clientProvider).getRoomById(widget.roomId);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
Notifications.activeRoomId = widget.roomId;
Notifications.clearRoom(widget.roomId);
_scroll.addListener(_onScroll);
_syncSub = ref.read(clientProvider).onSync.stream.listen((_) {
if (mounted) setState(() {});
});
_load();
}
Future<void> _load() async {
final room = _room;
if (room == null) return;
try {
final timeline = await room.getTimeline(
onUpdate: _onTimelineUpdate,
onNewEvent: _markRead,
);
if (!mounted) {
timeline.cancelSubscriptions();
return;
}
setState(() => _timeline = timeline);
_markRead();
// A short room may not fill the screen: fetch more until it does.
WidgetsBinding.instance.addPostFrameCallback((_) => _onScroll());
} catch (e) {
if (mounted) setState(() => _loadError = e);
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
if (Notifications.activeRoomId == widget.roomId) {
Notifications.activeRoomId = null;
}
_timeline?.cancelSubscriptions();
_syncSub?.cancel();
_scroll.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) _markRead();
}
void _onTimelineUpdate() {
if (mounted) setState(() {});
}
/// Sends a read receipt for the newest event, while the room is on screen
/// and the app is in front.
void _markRead() {
final timeline = _timeline;
final room = _room;
if (timeline == null || room == null) return;
if (WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed) {
return;
}
if (room.membership != Membership.join) return;
if (room.notificationCount == 0 &&
!room.markedUnread &&
room.receiptState.global.latestOwnReceipt?.eventId ==
timeline.events.firstOrNull?.eventId) {
return;
}
if (room.markedUnread) room.markUnread(false);
timeline.setReadMarker().catchError((Object e) {
Logs().w('Could not send a read receipt', e);
});
Notifications.clearRoom(room.id);
}
void _onScroll() {
final timeline = _timeline;
if (timeline == null || _loadingHistory || !timeline.canRequestHistory) {
return;
}
if (!_scroll.hasClients) return;
final position = _scroll.position;
if (position.pixels < position.maxScrollExtent - 600) return;
setState(() => _loadingHistory = true);
timeline
.requestHistory()
.catchError((Object e) => Logs().w('Could not load history', e))
.whenComplete(() {
if (mounted) setState(() => _loadingHistory = false);
});
}
@override
Widget build(BuildContext context) {
final room = _room;
if (room == null) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: Text("This room isn't available.")),
);
}
return Scaffold(
appBar: _RoomAppBar(room: room),
body: Column(
children: [
_CallBanner(room: room),
Expanded(child: _buildTimeline(room)),
if (room.membership == Membership.invite)
_InviteBar(room: room)
else if (room.membership == Membership.join)
room.canSendDefaultMessages
? Composer(
room: room,
replyTo: _replyTo,
onCancelReply: () => setState(() => _replyTo = null),
)
: const _ReadOnlyBar(),
],
),
);
}
Widget _buildTimeline(Room room) {
final timeline = _timeline;
if (_loadError != null) {
return Center(child: Text(describeError(_loadError!)));
}
if (timeline == null) {
return const Center(child: CircularProgressIndicator());
}
final events = timeline.events;
final items = projectTimeline([
for (final e in events)
(sender: e.senderId, time: e.originServerTs, kind: kindOf(e)),
]);
final readUpTo = _readByOthersIndex(events, room);
final typing = room.typingUsers
.where((u) => u.id != room.client.userID)
.toList();
return ListView.builder(
controller: _scroll,
reverse: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length + 2,
itemBuilder: (context, i) {
if (i == 0) return _TypingRow(users: typing);
if (i == items.length + 1) {
return _HistoryEnd(
loading: _loadingHistory || timeline.canRequestHistory,
room: room,
);
}
final item = items[i - 1];
return switch (item) {
DayItem(:final day) => _DaySeparator(day: day),
EventItem(:final index, :final kind) when kind == EntryKind.notice =>
NoticeRow(event: events[index]),
EventItem(:final index, :final firstInGroup, :final lastInGroup) =>
MessageRow(
key: ValueKey(events[index].eventId),
event: events[index],
timeline: timeline,
firstInGroup: firstInGroup,
lastInGroup: lastInGroup,
readByOthers: readUpTo != null && index >= readUpTo,
onReply: (e) => setState(() => _replyTo = e),
onOpenThread: (e) => Navigator.of(
context,
).push(ThreadScreen.route(room.id, e.eventId)),
),
};
},
);
}
/// The index (newest first) of the newest event someone else has read,
/// so the user's messages from there back say "Read".
static int? _readByOthersIndex(List<Event> events, Room room) {
final me = room.client.userID;
for (var i = 0; i < events.length; i++) {
if (events[i].receipts.any((r) => r.user.id != me)) return i;
}
return null;
}
}
class _RoomAppBar extends StatelessWidget implements PreferredSizeWidget {
const _RoomAppBar({required this.room});
final Room room;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
Widget build(BuildContext context) {
final c = context.warp;
final text = Theme.of(context).textTheme;
// A room fresh from /createRoom may have no summary yet.
final members =
room.summary.mJoinedMemberCount ?? room.getParticipants().length;
final subtitle = room.isDirectChat
? room.directChatMatrixID ?? ''
: members == 1
? '1 member'
: '$members members';
return AppBar(
titleSpacing: 0,
title: InkWell(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => RoomInfoScreen(roomId: room.id)),
),
child: Row(
children: [
Avatar(
name: room.getLocalizedDisplayname(),
seed: room.id,
mxc: room.avatar,
size: 36,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
room.getLocalizedDisplayname(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: text.titleMedium,
),
Row(
children: [
if (room.encrypted) ...[
Icon(Icons.lock_outline, size: 12, color: c.tealText),
const SizedBox(width: 3),
],
Flexible(
child: Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: text.labelSmall,
),
),
],
),
],
),
),
],
),
),
actions: [
_CallButtons(room: room),
IconButton(
tooltip: 'Room info',
icon: const Icon(Icons.info_outline),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => RoomInfoScreen(roomId: room.id)),
),
),
],
);
}
}
/// Voice and video call buttons; "back to the call" while in this room's.
class _CallButtons extends ConsumerWidget {
const _CallButtons({required this.room});
final Room room;
@override
Widget build(BuildContext context, WidgetRef ref) {
final calls = ref.watch(callsProvider);
return ListenableBuilder(
listenable: calls,
builder: (context, _) {
if (room.membership != Membership.join) return const SizedBox.shrink();
if (calls.roomId == room.id) {
return IconButton(
tooltip: 'Back to the call',
icon: Icon(Icons.call, color: context.warp.tealText),
onPressed: () => Navigator.of(context).push(CallScreen.route()),
);
}
void start(bool video) {
calls.join(room.id, video: video);
Navigator.of(context).push(CallScreen.route());
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Voice call',
icon: const Icon(Icons.call_outlined),
onPressed: () => start(false),
),
IconButton(
tooltip: 'Video call',
icon: const Icon(Icons.videocam_outlined),
onPressed: () => start(true),
),
],
);
},
);
}
}
/// "A call is going on" with Join, while this device isn't in it.
class _CallBanner extends ConsumerWidget {
const _CallBanner({required this.room});
final Room room;
@override
Widget build(BuildContext context, WidgetRef ref) {
final calls = ref.watch(callsProvider);
return ListenableBuilder(
listenable: calls,
builder: (context, _) {
final count = calls.inCallCount(room.id);
if (count == 0 || calls.roomId == room.id) {
return const SizedBox.shrink();
}
final c = context.warp;
return Material(
color: c.tealSoft,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 8, 6),
child: Row(
children: [
Icon(Icons.call, color: c.tealText, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
count == 1
? '1 person in the call'
: '$count people in the call',
style: TextStyle(color: c.tealText),
),
),
FilledButton(
onPressed: () {
calls.join(room.id, video: false);
Navigator.of(context).push(CallScreen.route());
},
child: const Text('Join'),
),
],
),
),
);
},
);
}
}
class _DaySeparator extends StatelessWidget {
const _DaySeparator({required this.day});
final DateTime day;
@override
Widget build(BuildContext context) {
final c = context.warp;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Row(
children: [
Expanded(child: Divider(color: c.line)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
formatDay(day),
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(color: c.inkMuted),
),
),
Expanded(child: Divider(color: c.line)),
],
),
);
}
}
class _HistoryEnd extends StatelessWidget {
const _HistoryEnd({required this.loading, required this.room});
final bool loading;
final Room room;
@override
Widget build(BuildContext context) {
if (loading) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
final text = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Column(
children: [
Avatar(
name: room.getLocalizedDisplayname(),
seed: room.id,
mxc: room.avatar,
size: 64,
),
const SizedBox(height: 12),
Text(
room.getLocalizedDisplayname(),
style: text.titleLarge,
textAlign: TextAlign.center,
),
if (room.topic.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
room.topic,
style: text.bodySmall,
textAlign: TextAlign.center,
),
],
const SizedBox(height: 8),
Text(
'This is the beginning of the conversation.',
style: text.bodySmall,
),
],
),
);
}
}
class _TypingRow extends StatelessWidget {
const _TypingRow({required this.users});
final List<User> users;
@override
Widget build(BuildContext context) {
if (users.isEmpty) return const SizedBox(height: 4);
final names = users.map((u) => u.calcDisplayname()).toList();
final who = switch (names.length) {
1 => '${names[0]} is typing…',
2 => '${names[0]} and ${names[1]} are typing…',
_ => 'Several people are typing…',
};
return Padding(
padding: const EdgeInsets.fromLTRB(60, 4, 16, 8),
child: Text(
who,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: context.warp.inkMuted,
fontStyle: FontStyle.italic,
),
),
);
}
}
class _InviteBar extends StatefulWidget {
const _InviteBar({required this.room});
final Room room;
@override
State<_InviteBar> createState() => _InviteBarState();
}
class _InviteBarState extends State<_InviteBar> {
bool _busy = false;
Future<void> _act(bool join) async {
setState(() => _busy = true);
try {
if (join) {
await widget.room.join();
} else {
await widget.room.leave();
if (mounted) Navigator.pop(context);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(describeError(e))));
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"You've been invited to this room.",
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _busy ? null : () => _act(false),
child: const Text('Decline'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: _busy ? null : () => _act(true),
child: const Text('Join'),
),
),
],
),
],
),
),
);
}
}
class _ReadOnlyBar extends StatelessWidget {
const _ReadOnlyBar();
@override
Widget build(BuildContext context) => SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
"You can't send messages in this room.",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
);
}