feat: Phase 3 — E2EE calls, Jitsi conferencing, help tab, web security model

- LiveKit call E2EE: CallE2EEManager exchanges encryption keys via Matrix
  to-device events (m.rtc.encryption_keys) for interop with Element X
- Olm bootstrapped in index.html before Flutter init; main.dart logs result
- Encrypted messages shown with lock icon and informative fallback text
- Profile screen: key restore dialog + security setup (cross-signing/backup)
- Jitsi feature: welcome screen (public, no login), conference tab, full-screen
  embed via JitsiMeetExternalAPI, JitsiLink parser for all common link formats
- Help tab: expandable cards for encryption, video calls, account management
- Web security model: no session persistence — device ID only across visits
- Media auth: MSC3916 authenticated endpoint for avatars (Synapse 1.120+)
- Router: welcome route as public landing page; jitsi route as public
- Manifest/index.html: M8Chat branding, dark theme colours

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 05:28:14 +10:00
parent a241d0a7ab
commit b95471b0b4
27 changed files with 2076 additions and 226 deletions

View File

@@ -0,0 +1,168 @@
// Version: 1.1.0 | Created: 2026-04-05 | Updated: 2026-04-10
// Web implementation: uses JitsiMeetExternalAPI via dart:js_interop.
// This file is web-only — guarded by the Flutter web build.
// The external_api.js script is lazy-loaded on first use — not in index.html.
// This prevents privacy browsers (Brave, Mullvad) from blocking the app
// at startup due to a cross-origin script from conf.m8chat.au.
import 'dart:async';
import 'dart:js_interop';
import 'dart:ui_web' as ui_web;
import 'package:web/web.dart' as web;
import '../../../core/config/app_config.dart';
/// Manages a single Jitsi iframe session via the JitsiMeetExternalAPI.
class JitsiWebService {
JitsiWebService._();
static final instance = JitsiWebService._();
JSObject? _api;
bool _viewRegistered = false;
bool _scriptLoaded = false;
bool _scriptLoading = false;
/// Unique view type for HtmlElementView.
static const String viewType = 'jitsi-meet-container';
/// Lazy-loads the JitsiMeetExternalAPI script from conf.m8chat.au.
/// Returns true if the script loaded successfully, false otherwise.
Future<bool> _ensureScriptLoaded() async {
if (_scriptLoaded) return true;
if (_scriptLoading) {
// Wait for in-flight load to finish.
for (var i = 0; i < 50; i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
if (_scriptLoaded) return true;
}
return false;
}
_scriptLoading = true;
final completer = Completer<bool>();
final script =
web.document.createElement('script') as web.HTMLScriptElement;
script.src = 'https://${AppConfig.jitsiDomain}/external_api.js';
script.async = true;
script.onload = (web.Event event) {
_scriptLoaded = true;
_scriptLoading = false;
completer.complete(true);
}.toJS;
script.onerror = (web.Event event) {
_scriptLoading = false;
completer.complete(false);
}.toJS;
web.document.head?.append(script);
return completer.future;
}
/// Registers the platform view factory (once).
void ensureViewRegistered() {
if (_viewRegistered) return;
ui_web.platformViewRegistry.registerViewFactory(
viewType,
(int viewId, {Object? params}) {
final div = web.document.createElement('div') as web.HTMLDivElement;
div.id = 'jitsi-container-$viewId';
div.style
..width = '100%'
..height = '100%';
return div;
},
);
_viewRegistered = true;
}
/// Starts a Jitsi meeting inside the container div created by the platform view.
/// Call this AFTER the HtmlElementView has been mounted (e.g. in a post-frame callback).
/// Lazy-loads the Jitsi script on first call.
Future<void> joinMeeting({
required String roomName,
String? jwt,
String? displayName,
String? avatarUrl,
void Function()? onReadyToClose,
}) async {
dispose(); // tear down any previous meeting
// Lazy-load the external_api.js script if not already present.
final loaded = await _ensureScriptLoaded();
if (!loaded) return;
// Find the container div — there should be exactly one with our prefix.
final containers = web.document.querySelectorAll('[id^="jitsi-container-"]');
if (containers.length == 0) return;
final parentNode = containers.item(containers.length - 1);
if (parentNode == null) return;
final configOverwrite = <String, Object?>{
'startAudioMuted': 0,
'startVideoMuted': 0,
'disableDeepLinking': true,
'prejoinPageEnabled': true,
}.jsify();
final interfaceConfigOverwrite = <String, Object?>{
'SHOW_CHROME_EXTENSION_BANNER': false,
}.jsify();
final options = <String, Object?>{
'roomName': roomName,
'parentNode': parentNode,
'width': '100%',
'height': '100%',
'configOverwrite': configOverwrite,
'interfaceConfigOverwrite': interfaceConfigOverwrite,
};
if (jwt != null && jwt.isNotEmpty) {
options['jwt'] = jwt;
}
if (displayName != null && displayName.isNotEmpty) {
options['userInfo'] = <String, Object?>{
'displayName': displayName,
if (avatarUrl != null) 'avatarUrl': avatarUrl,
}.jsify();
}
final jsOptions = options.jsify();
_api = _createJitsiApi(AppConfig.jitsiDomain.toJS, jsOptions as JSObject);
if (onReadyToClose != null && _api != null) {
_addEventListener(_api!, 'readyToClose'.toJS, () {
onReadyToClose();
}.toJS);
}
}
/// Cleans up the Jitsi iframe.
void dispose() {
if (_api != null) {
_disposeApi(_api!);
_api = null;
}
}
}
/// Calls `new JitsiMeetExternalAPI(domain, options)`.
@JS('JitsiMeetExternalAPI')
external JSObject _createJitsiApi(JSString domain, JSObject options);
/// Calls `api.addEventListener(event, callback)`.
@JS()
extension type _JitsiApi(JSObject _) implements JSObject {
external void addEventListener(JSString event, JSFunction callback);
external void dispose();
}
void _addEventListener(JSObject api, JSString event, JSFunction callback) {
(api as _JitsiApi).addEventListener(event, callback);
}
void _disposeApi(JSObject api) {
(api as _JitsiApi).dispose();
}

View File

@@ -0,0 +1,43 @@
// Version: 1.0.0 | Created: 2026-04-05
// Parses a Jitsi meeting link into room name and optional JWT.
/// Parsed Jitsi meeting link.
class JitsiLink {
const JitsiLink({required this.roomName, this.jwt});
final String roomName;
final String? jwt;
/// Parses a Jitsi link in common formats:
/// - `https://conf.m8chat.au/RoomName`
/// - `https://conf.m8chat.au/RoomName?jwt=abc123`
/// - `conf.m8chat.au/RoomName?jwt=abc123`
/// - `RoomName` (bare room name)
static JitsiLink? tryParse(String input) {
final trimmed = input.trim();
if (trimmed.isEmpty) return null;
// Try as a URL first.
var uriString = trimmed;
if (!uriString.contains('://') && uriString.contains('/')) {
uriString = 'https://$uriString';
}
final uri = Uri.tryParse(uriString);
if (uri != null && uri.pathSegments.isNotEmpty && uri.host.isNotEmpty) {
final roomName = uri.pathSegments
.where((s) => s.isNotEmpty)
.join('/');
if (roomName.isEmpty) return null;
final jwt = uri.queryParameters['jwt'];
return JitsiLink(roomName: roomName, jwt: jwt);
}
// Bare room name — no slashes, no protocol.
if (!trimmed.contains('/') && !trimmed.contains(' ')) {
return JitsiLink(roomName: trimmed);
}
return null;
}
}

View File

@@ -0,0 +1,96 @@
// Version: 1.0.0 | Created: 2026-04-10
// Embedded conference join widget for the bottom nav tab.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// Inline widget shown inside the RoomsScreen bottom nav.
/// Lets authenticated users paste a Jitsi meeting link and join.
class ConferenceTab extends StatefulWidget {
const ConferenceTab({super.key});
@override
State<ConferenceTab> createState() => _ConferenceTabState();
}
class _ConferenceTabState extends State<ConferenceTab> {
final _linkController = TextEditingController();
@override
void dispose() {
_linkController.dispose();
super.dispose();
}
void _joinConference() {
final link = _linkController.text.trim();
if (link.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please paste a meeting link.')),
);
return;
}
context.push('/jitsi', extra: link);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.videocam_outlined,
size: 64,
color: theme.colorScheme.primary.withAlpha(180),
),
const SizedBox(height: 16),
Text(
'Join a Conference',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Paste your meeting link below to join.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
const SizedBox(height: 32),
TextField(
controller: _linkController,
decoration: const InputDecoration(
hintText: 'conf.m8chat.au/YourMeeting',
prefixIcon: Icon(Icons.link),
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.go,
onSubmitted: (_) => _joinConference(),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _joinConference,
icon: const Icon(Icons.videocam),
label: const Text('Join Meeting'),
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(52),
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,121 @@
// Version: 1.2.0 | Created: 2026-04-05 | Updated: 2026-04-10
// Full-screen Jitsi meeting embedded via HtmlElementView (web platform view).
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../data/jitsi_web_service.dart';
import '../domain/jitsi_link.dart';
class JitsiScreen extends StatefulWidget {
const JitsiScreen({super.key, required this.meetingUrl});
/// The raw meeting link or room name provided by the user.
final String meetingUrl;
@override
State<JitsiScreen> createState() => _JitsiScreenState();
}
class _JitsiScreenState extends State<JitsiScreen> {
late final JitsiLink? _link;
bool _meetingStarted = false;
bool _meetingEnded = false;
@override
void initState() {
super.initState();
_link = JitsiLink.tryParse(widget.meetingUrl);
if (_link != null) {
JitsiWebService.instance.ensureViewRegistered();
}
}
@override
void dispose() {
JitsiWebService.instance.dispose();
super.dispose();
}
void _startMeeting() {
if (_link == null || _meetingStarted) return;
_meetingStarted = true;
// Post-frame so the HtmlElementView div is mounted in the DOM.
WidgetsBinding.instance.addPostFrameCallback((_) {
JitsiWebService.instance.joinMeeting(
roomName: _link.roomName,
jwt: _link.jwt,
onReadyToClose: () {
if (mounted) {
setState(() => _meetingEnded = true);
}
},
);
});
}
@override
Widget build(BuildContext context) {
if (_link == null) {
return Scaffold(
appBar: AppBar(title: const Text('Conference')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.link_off, size: 48),
const SizedBox(height: 16),
const Text('Could not parse the meeting link.'),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.go('/rooms'),
child: const Text('Back'),
),
],
),
),
),
);
}
if (_meetingEnded) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.videocam_off_outlined,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Meeting ended',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.go('/rooms'),
child: const Text('Back to M8Chat'),
),
],
),
),
),
);
}
// Render the Jitsi iframe and kick off the meeting once mounted.
_startMeeting();
return Scaffold(
body: const HtmlElementView(viewType: JitsiWebService.viewType),
);
}
}

View File

@@ -0,0 +1,218 @@
// Version: 1.0.0 | Created: 2026-04-05
// Welcome/landing screen — first thing unauthenticated users see.
// Two paths: join a Jitsi conference (no login) or sign in for Matrix chat.
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
class WelcomeScreen extends StatefulWidget {
const WelcomeScreen({super.key});
@override
State<WelcomeScreen> createState() => _WelcomeScreenState();
}
class _WelcomeScreenState extends State<WelcomeScreen> {
final _linkController = TextEditingController();
@override
void dispose() {
_linkController.dispose();
super.dispose();
}
void _joinConference() {
final link = _linkController.text.trim();
if (link.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please paste a meeting link.')),
);
return;
}
context.go('/jitsi', extra: link);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// --- Logo & tagline ---
Column(
children: [
SvgPicture.asset(
'assets/images/m8logo.svg',
width: 80,
height: 80,
),
const SizedBox(height: 16),
Text(
'M8Chat',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 8),
Text(
'Chat and conference — all in one place',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
],
),
const SizedBox(height: 48),
// --- Conference section ---
_SectionCard(
theme: theme,
isDark: isDark,
icon: Icons.videocam_outlined,
title: 'Join a Conference',
subtitle: 'Paste your meeting link to join instantly — no account needed.',
child: Column(
children: [
TextField(
controller: _linkController,
decoration: const InputDecoration(
hintText: 'conf.m8chat.au/YourMeeting',
prefixIcon: Icon(Icons.link),
),
textInputAction: TextInputAction.go,
onSubmitted: (_) => _joinConference(),
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _joinConference,
icon: const Icon(Icons.videocam),
label: const Text('Join Meeting'),
),
],
),
),
const SizedBox(height: 20),
// --- Divider ---
Row(
children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'or',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(102),
),
),
),
const Expanded(child: Divider()),
],
),
const SizedBox(height: 20),
// --- Sign in section ---
_SectionCard(
theme: theme,
isDark: isDark,
icon: Icons.chat_bubble_outline,
title: 'Sign In to Chat',
subtitle: 'Access your rooms, messages and video calls.',
child: OutlinedButton.icon(
onPressed: () => context.go('/login'),
icon: const Icon(Icons.login),
label: const Text('Sign In'),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide(color: theme.colorScheme.primary),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
),
),
),
);
}
}
/// Styled card for each welcome section.
class _SectionCard extends StatelessWidget {
const _SectionCard({
required this.theme,
required this.isDark,
required this.icon,
required this.title,
required this.subtitle,
required this.child,
});
final ThemeData theme;
final bool isDark;
final IconData icon;
final String title;
final String subtitle;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: theme.colorScheme.primary, size: 28),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 8),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
const SizedBox(height: 16),
child,
],
),
),
);
}
}