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:
168
lib/features/jitsi/data/jitsi_web_service.dart
Normal file
168
lib/features/jitsi/data/jitsi_web_service.dart
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user