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>
668 lines
21 KiB
Dart
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),
|
|
);
|
|
}),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|