- 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>
169 lines
5.1 KiB
Dart
169 lines
5.1 KiB
Dart
// 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();
|
|
}
|