From 923c0ad878a9eaaef639cbb5b49342d32daef6b0 Mon Sep 17 00:00:00 2001 From: help4bis Date: Sat, 4 Jul 2026 07:05:32 +1000 Subject: [PATCH] =?UTF-8?q?fix:=20Jitsi=20meeting=20join=20rendered=20blan?= =?UTF-8?q?k=20page=20=E2=80=94=20call=20JitsiMeetExternalAPI=20with=20'ne?= =?UTF-8?q?w'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bind JitsiMeetExternalAPI as extension type external constructor; the old external function binding invoked the ES6 class without 'new' and threw at runtime (silently, in release builds) - joinMeeting: wait up to 5s for platform-view container div (post-frame race), build options via setProperty (jsify cannot carry DOM nodes), return success bool, log failures to browser console - jitsi_screen: show 'Could not start the meeting' UI instead of blank - v1.6.2+10, deployed to app2.m8chat.au Co-Authored-By: Claude Fable 5 --- help4bis-claude-notes/decisions.md | 7 ++ lib/core/config/app_config.dart | 2 +- .../jitsi/data/jitsi_web_service.dart | 110 +++++++++++------- .../jitsi/presentation/jitsi_screen.dart | 39 ++++++- pubspec.yaml | 2 +- 5 files changed, 115 insertions(+), 45 deletions(-) diff --git a/help4bis-claude-notes/decisions.md b/help4bis-claude-notes/decisions.md index 8ba8308..f8b0864 100644 --- a/help4bis-claude-notes/decisions.md +++ b/help4bis-claude-notes/decisions.md @@ -75,3 +75,10 @@ - **Audit results:** Backend all healthy (client API, livekit JWT, Jitsi external_api.js, push gateway, TURN 3478/5349). Full E2E pass in headless Chromium: login, rooms, spaces, profile, conference, help tabs, room open, message send verified on Synapse. Disposable audit users created and erased via admin API. - **Files affected:** assets/images/m8logo.svg, lib/app/theme.dart (2.0.0), lib/core/config/app_config.dart (2.1.0), web/manifest.json, pubspec.yaml (1.6.0+8), em-dash fixes in 8 presentation/util files, chat_screen.dart lint fix (_leaveRoom context param). - **Status:** Done — built, deployed to app2.m8chat.au (backup public_html.bak.20260703_pre160.tar.gz), visually verified light+dark. + +### 2026-07-04 07:15 — Jitsi conference join fixed (v1.6.2+10) +- **Decision:** Root cause of "blank page on join" was the JS interop binding calling JitsiMeetExternalAPI (an ES6 class) as a plain function, i.e. without `new`. Browsers throw "Class constructor cannot be invoked without 'new'" and the meeting screen stayed blank. Feature had never worked in the web release build. +- **Fix:** `_JitsiApi` extension type with an external constructor (compiles to `new`), container-div wait loop in joinMeeting (post-frame callback could fire before the platform view div existed), JS options built via setProperty instead of Map.jsify (DOM node in a jsify map is unsupported), joinMeeting returns bool, jitsi_screen shows an error screen instead of blank on failure. +- **Verified:** headless Chromium on production build: iframe renders, prejoin page shows, devices pass, name entry works, reaches the Prosody JWT auth gate (expected for a tokenless room). COEP require-corp turned out NOT to block conf.m8chat.au in current Chromium. +- **Files affected:** lib/features/jitsi/data/jitsi_web_service.dart (1.2.0), lib/features/jitsi/presentation/jitsi_screen.dart (1.3.0), app_config 1.6.2, pubspec 1.6.2+10. +- **Status:** Done — deployed to app2.m8chat.au (backups pre160/pre161 tarballs on server). diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 3792968..304cd81 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -11,7 +11,7 @@ abstract final class AppConfig { 'turns:matrix.m8chat.au:5349', ]; static const String appName = 'M8Chat'; - static const String appVersion = '1.6.0'; + static const String appVersion = '1.6.2'; // Jitsi conferencing static const String jitsiDomain = 'conf.m8chat.au'; diff --git a/lib/features/jitsi/data/jitsi_web_service.dart b/lib/features/jitsi/data/jitsi_web_service.dart index 97205fb..fa796e5 100644 --- a/lib/features/jitsi/data/jitsi_web_service.dart +++ b/lib/features/jitsi/data/jitsi_web_service.dart @@ -1,4 +1,4 @@ -// Version: 1.1.0 | Created: 2026-04-05 | Updated: 2026-04-10 +// Version: 1.2.0 | Created: 2026-04-05 | Updated: 2026-07-04 // 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. @@ -7,6 +7,7 @@ import 'dart:async'; import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import 'dart:ui_web' as ui_web; import 'package:web/web.dart' as web; @@ -77,9 +78,14 @@ class JitsiWebService { } /// 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 joinMeeting({ + /// Call this AFTER the HtmlElementView has been scheduled for mounting + /// (e.g. in a post-frame callback). Lazy-loads the Jitsi script on first + /// call and waits for the platform-view container div to appear — the + /// post-frame callback can run before the HtmlElementView factory has + /// inserted the div, which previously made this method bail out silently + /// and leave the meeting screen blank. + /// Returns true if the meeting iframe was created. + Future joinMeeting({ required String roomName, String? jwt, String? displayName, @@ -90,53 +96,73 @@ class JitsiWebService { // Lazy-load the external_api.js script if not already present. final loaded = await _ensureScriptLoaded(); - if (!loaded) return; + if (!loaded) { + web.console.warn('[Jitsi] external_api.js failed to load'.toJS); + return false; + } - // 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; + // Wait for the container div — the platform view may not be in the DOM + // yet when the post-frame callback fires. + web.Node? parentNode; + for (var i = 0; i < 50; i++) { + final containers = + web.document.querySelectorAll('[id^="jitsi-container-"]'); + if (containers.length > 0) { + parentNode = containers.item(containers.length - 1); + break; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + if (parentNode == null) { + web.console.warn('[Jitsi] container div never appeared'.toJS); + return false; + } - final configOverwrite = { - 'startAudioMuted': 0, - 'startVideoMuted': 0, - 'disableDeepLinking': true, - 'prejoinPageEnabled': true, - }.jsify(); + // Build the options as a real JS object. Passing a DOM node through + // Map.jsify() is not supported and threw at runtime in the dart2js build. + final configOverwrite = JSObject() + ..setProperty('startAudioMuted'.toJS, 0.toJS) + ..setProperty('startVideoMuted'.toJS, 0.toJS) + ..setProperty('disableDeepLinking'.toJS, true.toJS) + ..setProperty('prejoinPageEnabled'.toJS, true.toJS); - final interfaceConfigOverwrite = { - 'SHOW_CHROME_EXTENSION_BANNER': false, - }.jsify(); + final interfaceConfigOverwrite = JSObject() + ..setProperty('SHOW_CHROME_EXTENSION_BANNER'.toJS, false.toJS); - final options = { - 'roomName': roomName, - 'parentNode': parentNode, - 'width': '100%', - 'height': '100%', - 'configOverwrite': configOverwrite, - 'interfaceConfigOverwrite': interfaceConfigOverwrite, - }; + final options = JSObject() + ..setProperty('roomName'.toJS, roomName.toJS) + ..setProperty('parentNode'.toJS, parentNode as JSAny) + ..setProperty('width'.toJS, '100%'.toJS) + ..setProperty('height'.toJS, '100%'.toJS) + ..setProperty('configOverwrite'.toJS, configOverwrite) + ..setProperty('interfaceConfigOverwrite'.toJS, interfaceConfigOverwrite); if (jwt != null && jwt.isNotEmpty) { - options['jwt'] = jwt; + options.setProperty('jwt'.toJS, jwt.toJS); } if (displayName != null && displayName.isNotEmpty) { - options['userInfo'] = { - 'displayName': displayName, - if (avatarUrl != null) 'avatarUrl': avatarUrl, - }.jsify(); + final userInfo = JSObject() + ..setProperty('displayName'.toJS, displayName.toJS); + if (avatarUrl != null) { + userInfo.setProperty('avatarUrl'.toJS, avatarUrl.toJS); + } + options.setProperty('userInfo'.toJS, userInfo); } - final jsOptions = options.jsify(); - _api = _createJitsiApi(AppConfig.jitsiDomain.toJS, jsOptions as JSObject); + try { + _api = _createJitsiApi(AppConfig.jitsiDomain.toJS, options); + } catch (e) { + web.console.error('[Jitsi] JitsiMeetExternalAPI threw: $e'.toJS); + return false; + } if (onReadyToClose != null && _api != null) { _addEventListener(_api!, 'readyToClose'.toJS, () { onReadyToClose(); }.toJS); } + return true; } /// Cleans up the Jitsi iframe. @@ -148,17 +174,21 @@ class JitsiWebService { } } -/// Calls `new JitsiMeetExternalAPI(domain, options)`. +/// Binding for the JitsiMeetExternalAPI ES6 class. +/// The external constructor compiles to `new JitsiMeetExternalAPI(...)`. +/// (A plain external function binding calls it WITHOUT `new`, which throws +/// "Class constructor cannot be invoked without 'new'" — the original cause +/// of the blank meeting screen.) @JS('JitsiMeetExternalAPI') -external JSObject _createJitsiApi(JSString domain, JSObject options); - -/// Calls `api.addEventListener(event, callback)`. -@JS() -extension type _JitsiApi(JSObject _) implements JSObject { +extension type _JitsiApi._(JSObject _) implements JSObject { + external _JitsiApi(JSString domain, JSObject options); external void addEventListener(JSString event, JSFunction callback); external void dispose(); } +JSObject _createJitsiApi(JSString domain, JSObject options) => + _JitsiApi(domain, options); + void _addEventListener(JSObject api, JSString event, JSFunction callback) { (api as _JitsiApi).addEventListener(event, callback); } diff --git a/lib/features/jitsi/presentation/jitsi_screen.dart b/lib/features/jitsi/presentation/jitsi_screen.dart index 030bcd9..f66f595 100644 --- a/lib/features/jitsi/presentation/jitsi_screen.dart +++ b/lib/features/jitsi/presentation/jitsi_screen.dart @@ -1,4 +1,4 @@ -// Version: 1.2.0 | Created: 2026-04-05 | Updated: 2026-04-10 +// Version: 1.3.0 | Created: 2026-04-05 | Updated: 2026-07-04 // Full-screen Jitsi meeting embedded via HtmlElementView (web platform view). import 'package:flutter/material.dart'; @@ -21,6 +21,7 @@ class _JitsiScreenState extends State { late final JitsiLink? _link; bool _meetingStarted = false; bool _meetingEnded = false; + bool _meetingFailed = false; @override void initState() { @@ -42,8 +43,9 @@ class _JitsiScreenState extends State { _meetingStarted = true; // Post-frame so the HtmlElementView div is mounted in the DOM. - WidgetsBinding.instance.addPostFrameCallback((_) { - JitsiWebService.instance.joinMeeting( + // joinMeeting itself waits for the container div to appear. + WidgetsBinding.instance.addPostFrameCallback((_) async { + final ok = await JitsiWebService.instance.joinMeeting( roomName: _link.roomName, jwt: _link.jwt, onReadyToClose: () { @@ -52,6 +54,9 @@ class _JitsiScreenState extends State { } }, ); + if (!ok && mounted) { + setState(() => _meetingFailed = true); + } }); } @@ -111,6 +116,34 @@ class _JitsiScreenState extends State { ); } + if (_meetingFailed) { + 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.videocam_off_outlined, size: 48), + const SizedBox(height: 16), + const Text( + 'Could not start the meeting. ' + 'Check your connection and try again.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => context.go('/rooms'), + child: const Text('Back'), + ), + ], + ), + ), + ), + ); + } + // Render the Jitsi iframe and kick off the meeting once mounted. _startMeeting(); diff --git a/pubspec.yaml b/pubspec.yaml index cf1f1da..487d8ef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: m8chat_app description: "M8Chat — Matrix chat client for Android, iOS, and Web." publish_to: 'none' -version: 1.6.0+8 +version: 1.6.2+10 environment: sdk: '>=3.11.0 <4.0.0'