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>
601 lines
17 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|