fix: Jitsi meeting join rendered blank page — call JitsiMeetExternalAPI with 'new'
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
- **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).
|
- **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.
|
- **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).
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ abstract final class AppConfig {
|
|||||||
'turns:matrix.m8chat.au:5349',
|
'turns:matrix.m8chat.au:5349',
|
||||||
];
|
];
|
||||||
static const String appName = 'M8Chat';
|
static const String appName = 'M8Chat';
|
||||||
static const String appVersion = '1.6.0';
|
static const String appVersion = '1.6.2';
|
||||||
|
|
||||||
// Jitsi conferencing
|
// Jitsi conferencing
|
||||||
static const String jitsiDomain = 'conf.m8chat.au';
|
static const String jitsiDomain = 'conf.m8chat.au';
|
||||||
|
|||||||
@@ -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.
|
// Web implementation: uses JitsiMeetExternalAPI via dart:js_interop.
|
||||||
// This file is web-only — guarded by the Flutter web build.
|
// 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.
|
// The external_api.js script is lazy-loaded on first use — not in index.html.
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:js_interop';
|
import 'dart:js_interop';
|
||||||
|
import 'dart:js_interop_unsafe';
|
||||||
import 'dart:ui_web' as ui_web;
|
import 'dart:ui_web' as ui_web;
|
||||||
|
|
||||||
import 'package:web/web.dart' as 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.
|
/// 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).
|
/// Call this AFTER the HtmlElementView has been scheduled for mounting
|
||||||
/// Lazy-loads the Jitsi script on first call.
|
/// (e.g. in a post-frame callback). Lazy-loads the Jitsi script on first
|
||||||
Future<void> joinMeeting({
|
/// 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<bool> joinMeeting({
|
||||||
required String roomName,
|
required String roomName,
|
||||||
String? jwt,
|
String? jwt,
|
||||||
String? displayName,
|
String? displayName,
|
||||||
@@ -90,53 +96,73 @@ class JitsiWebService {
|
|||||||
|
|
||||||
// Lazy-load the external_api.js script if not already present.
|
// Lazy-load the external_api.js script if not already present.
|
||||||
final loaded = await _ensureScriptLoaded();
|
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.
|
// Wait for the container div — the platform view may not be in the DOM
|
||||||
final containers = web.document.querySelectorAll('[id^="jitsi-container-"]');
|
// yet when the post-frame callback fires.
|
||||||
if (containers.length == 0) return;
|
web.Node? parentNode;
|
||||||
final parentNode = containers.item(containers.length - 1);
|
for (var i = 0; i < 50; i++) {
|
||||||
if (parentNode == null) return;
|
final containers =
|
||||||
|
web.document.querySelectorAll('[id^="jitsi-container-"]');
|
||||||
|
if (containers.length > 0) {
|
||||||
|
parentNode = containers.item(containers.length - 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
if (parentNode == null) {
|
||||||
|
web.console.warn('[Jitsi] container div never appeared'.toJS);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
final configOverwrite = <String, Object?>{
|
// Build the options as a real JS object. Passing a DOM node through
|
||||||
'startAudioMuted': 0,
|
// Map.jsify() is not supported and threw at runtime in the dart2js build.
|
||||||
'startVideoMuted': 0,
|
final configOverwrite = JSObject()
|
||||||
'disableDeepLinking': true,
|
..setProperty('startAudioMuted'.toJS, 0.toJS)
|
||||||
'prejoinPageEnabled': true,
|
..setProperty('startVideoMuted'.toJS, 0.toJS)
|
||||||
}.jsify();
|
..setProperty('disableDeepLinking'.toJS, true.toJS)
|
||||||
|
..setProperty('prejoinPageEnabled'.toJS, true.toJS);
|
||||||
|
|
||||||
final interfaceConfigOverwrite = <String, Object?>{
|
final interfaceConfigOverwrite = JSObject()
|
||||||
'SHOW_CHROME_EXTENSION_BANNER': false,
|
..setProperty('SHOW_CHROME_EXTENSION_BANNER'.toJS, false.toJS);
|
||||||
}.jsify();
|
|
||||||
|
|
||||||
final options = <String, Object?>{
|
final options = JSObject()
|
||||||
'roomName': roomName,
|
..setProperty('roomName'.toJS, roomName.toJS)
|
||||||
'parentNode': parentNode,
|
..setProperty('parentNode'.toJS, parentNode as JSAny)
|
||||||
'width': '100%',
|
..setProperty('width'.toJS, '100%'.toJS)
|
||||||
'height': '100%',
|
..setProperty('height'.toJS, '100%'.toJS)
|
||||||
'configOverwrite': configOverwrite,
|
..setProperty('configOverwrite'.toJS, configOverwrite)
|
||||||
'interfaceConfigOverwrite': interfaceConfigOverwrite,
|
..setProperty('interfaceConfigOverwrite'.toJS, interfaceConfigOverwrite);
|
||||||
};
|
|
||||||
|
|
||||||
if (jwt != null && jwt.isNotEmpty) {
|
if (jwt != null && jwt.isNotEmpty) {
|
||||||
options['jwt'] = jwt;
|
options.setProperty('jwt'.toJS, jwt.toJS);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (displayName != null && displayName.isNotEmpty) {
|
if (displayName != null && displayName.isNotEmpty) {
|
||||||
options['userInfo'] = <String, Object?>{
|
final userInfo = JSObject()
|
||||||
'displayName': displayName,
|
..setProperty('displayName'.toJS, displayName.toJS);
|
||||||
if (avatarUrl != null) 'avatarUrl': avatarUrl,
|
if (avatarUrl != null) {
|
||||||
}.jsify();
|
userInfo.setProperty('avatarUrl'.toJS, avatarUrl.toJS);
|
||||||
|
}
|
||||||
|
options.setProperty('userInfo'.toJS, userInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
final jsOptions = options.jsify();
|
try {
|
||||||
_api = _createJitsiApi(AppConfig.jitsiDomain.toJS, jsOptions as JSObject);
|
_api = _createJitsiApi(AppConfig.jitsiDomain.toJS, options);
|
||||||
|
} catch (e) {
|
||||||
|
web.console.error('[Jitsi] JitsiMeetExternalAPI threw: $e'.toJS);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (onReadyToClose != null && _api != null) {
|
if (onReadyToClose != null && _api != null) {
|
||||||
_addEventListener(_api!, 'readyToClose'.toJS, () {
|
_addEventListener(_api!, 'readyToClose'.toJS, () {
|
||||||
onReadyToClose();
|
onReadyToClose();
|
||||||
}.toJS);
|
}.toJS);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cleans up the Jitsi iframe.
|
/// 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')
|
@JS('JitsiMeetExternalAPI')
|
||||||
external JSObject _createJitsiApi(JSString domain, JSObject options);
|
extension type _JitsiApi._(JSObject _) implements JSObject {
|
||||||
|
external _JitsiApi(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 addEventListener(JSString event, JSFunction callback);
|
||||||
external void dispose();
|
external void dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JSObject _createJitsiApi(JSString domain, JSObject options) =>
|
||||||
|
_JitsiApi(domain, options);
|
||||||
|
|
||||||
void _addEventListener(JSObject api, JSString event, JSFunction callback) {
|
void _addEventListener(JSObject api, JSString event, JSFunction callback) {
|
||||||
(api as _JitsiApi).addEventListener(event, callback);
|
(api as _JitsiApi).addEventListener(event, callback);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
// Full-screen Jitsi meeting embedded via HtmlElementView (web platform view).
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -21,6 +21,7 @@ class _JitsiScreenState extends State<JitsiScreen> {
|
|||||||
late final JitsiLink? _link;
|
late final JitsiLink? _link;
|
||||||
bool _meetingStarted = false;
|
bool _meetingStarted = false;
|
||||||
bool _meetingEnded = false;
|
bool _meetingEnded = false;
|
||||||
|
bool _meetingFailed = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -42,8 +43,9 @@ class _JitsiScreenState extends State<JitsiScreen> {
|
|||||||
_meetingStarted = true;
|
_meetingStarted = true;
|
||||||
|
|
||||||
// Post-frame so the HtmlElementView div is mounted in the DOM.
|
// Post-frame so the HtmlElementView div is mounted in the DOM.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
// joinMeeting itself waits for the container div to appear.
|
||||||
JitsiWebService.instance.joinMeeting(
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
final ok = await JitsiWebService.instance.joinMeeting(
|
||||||
roomName: _link.roomName,
|
roomName: _link.roomName,
|
||||||
jwt: _link.jwt,
|
jwt: _link.jwt,
|
||||||
onReadyToClose: () {
|
onReadyToClose: () {
|
||||||
@@ -52,6 +54,9 @@ class _JitsiScreenState extends State<JitsiScreen> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (!ok && mounted) {
|
||||||
|
setState(() => _meetingFailed = true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +116,34 @@ class _JitsiScreenState extends State<JitsiScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// Render the Jitsi iframe and kick off the meeting once mounted.
|
||||||
_startMeeting();
|
_startMeeting();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: m8chat_app
|
name: m8chat_app
|
||||||
description: "M8Chat — Matrix chat client for Android, iOS, and Web."
|
description: "M8Chat — Matrix chat client for Android, iOS, and Web."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.6.0+8
|
version: 1.6.2+10
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.11.0 <4.0.0'
|
sdk: '>=3.11.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user