151 lines
4.1 KiB
Dart
151 lines
4.1 KiB
Dart
// SPDX-FileCopyrightText: 2026 TatuChat
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import 'dart:js_interop';
|
|
import 'dart:ui_web' as ui_web;
|
|
|
|
import 'package:material_ui/material_ui.dart';
|
|
import 'package:tatchat/l10n/l10n.dart';
|
|
import 'package:tatchat/utils/miniapp_bridge.dart';
|
|
import 'package:web/web.dart' as web;
|
|
|
|
/// Web implementation of [MiniAppShell] rendering the mini app into a sandboxed
|
|
/// iframe.
|
|
///
|
|
/// The mini app talks to TatuChat through `postMessage`. This widget listens on
|
|
/// `window` for `fromMiniApp` requests, forwards them to the Dart bridge and
|
|
/// posts the response back to the iframe's `contentWindow`.
|
|
class MiniAppShell extends StatefulWidget {
|
|
final String html;
|
|
final String appId;
|
|
final String title;
|
|
final Future<Map<String, Object?>> Function(Map<String, dynamic> request)
|
|
onRequest;
|
|
|
|
const MiniAppShell({
|
|
super.key,
|
|
required this.html,
|
|
required this.appId,
|
|
required this.title,
|
|
required this.onRequest,
|
|
});
|
|
|
|
@override
|
|
State<MiniAppShell> createState() => _MiniAppShellState();
|
|
}
|
|
|
|
class _MiniAppShellState extends State<MiniAppShell> {
|
|
static int _viewTypeCounter = 0;
|
|
late final String _viewType;
|
|
web.HTMLIFrameElement? _iframe;
|
|
web.HTMLDivElement? _frameElement;
|
|
JSFunction? _messageListener;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_viewType = 'tatchat-miniapp-${_viewTypeCounter++}';
|
|
ui_web.platformViewRegistry.registerViewFactory(
|
|
_viewType,
|
|
(int viewId) => _createElement(),
|
|
);
|
|
final messageListener = _onMessage.toJS;
|
|
_messageListener = messageListener;
|
|
web.window.addEventListener('message', messageListener);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
final listener = _messageListener;
|
|
if (listener != null) {
|
|
web.window.removeEventListener('message', listener);
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
web.HTMLIFrameElement _buildIframe() {
|
|
final iframe =
|
|
web.document.createElement('iframe') as web.HTMLIFrameElement;
|
|
iframe.style
|
|
..width = '100%'
|
|
..height = '100%'
|
|
..border = 'none';
|
|
iframe.setAttribute(
|
|
'sandbox',
|
|
'allow-scripts allow-forms allow-same-origin',
|
|
);
|
|
iframe.setAttribute('srcdoc', widget.html);
|
|
return iframe;
|
|
}
|
|
|
|
web.HTMLElement _createElement() {
|
|
final div = web.document.createElement('div') as web.HTMLDivElement;
|
|
div.style
|
|
..width = '100%'
|
|
..height = '100%'
|
|
..margin = '0'
|
|
..padding = '0';
|
|
final iframe = _buildIframe();
|
|
div.append(iframe);
|
|
_iframe = iframe;
|
|
_frameElement = div;
|
|
return div;
|
|
}
|
|
|
|
void _reload() {
|
|
final div = _frameElement;
|
|
if (div == null) return;
|
|
_iframe?.remove();
|
|
final iframe = _buildIframe();
|
|
div.append(iframe);
|
|
_iframe = iframe;
|
|
}
|
|
|
|
void _onMessage(web.Event event) {
|
|
if (!event.isA<web.MessageEvent>()) return;
|
|
final messageEvent = event as web.MessageEvent;
|
|
final data = messageEvent.data?.dartify();
|
|
if (data is! Map) return;
|
|
final request = Map<String, dynamic>.from(data);
|
|
if (request['api'] != MiniappBridge.api) return;
|
|
final iframeWindow = _iframe?.contentWindow;
|
|
if (iframeWindow == null) return;
|
|
final requestId = request['requestId'];
|
|
final widgetId = request['widgetId'];
|
|
widget.onRequest(request).then((result) {
|
|
iframeWindow.postMessage(
|
|
{
|
|
'api': MiniappBridge.api,
|
|
'widgetId': widgetId,
|
|
'requestId': requestId,
|
|
'response': result,
|
|
}.jsify(),
|
|
'*'.toJS,
|
|
);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(widget.title),
|
|
backgroundColor: theme.colorScheme.surface,
|
|
surfaceTintColor: Colors.transparent,
|
|
foregroundColor: theme.colorScheme.onSurface,
|
|
actions: [
|
|
IconButton(
|
|
tooltip: L10n.of(context).refresh,
|
|
icon: const Icon(Icons.refresh_rounded),
|
|
onPressed: _reload,
|
|
),
|
|
],
|
|
),
|
|
backgroundColor: theme.colorScheme.surface,
|
|
body: HtmlElementView(viewType: _viewType),
|
|
);
|
|
}
|
|
}
|