diff --git a/assets/miniapps/accountdata.html b/assets/miniapps/accountdata.html
new file mode 100644
index 0000000..bb76bfb
--- /dev/null
+++ b/assets/miniapps/accountdata.html
@@ -0,0 +1,478 @@
+
+
+
+
+
+Account Data
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Add new key
+
The content must be a valid JSON object.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/assets/miniapps/tasks.html b/assets/miniapps/tasks.html
new file mode 100644
index 0000000..cba45ba
--- /dev/null
+++ b/assets/miniapps/tasks.html
@@ -0,0 +1,209 @@
+
+
+
+
+
+Tasks
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/miniapps.md b/docs/miniapps.md
new file mode 100644
index 0000000..d44f844
--- /dev/null
+++ b/docs/miniapps.md
@@ -0,0 +1,178 @@
+# TatuChat Mini Apps
+
+Mini apps are small, self-contained HTML documents that run inside TatuChat in a
+sandboxed web view. They can read/write their own key-value store, inspect the
+user's profile and read/write arbitrary account data through a tiny messaging
+bridge. There are no dependencies and no build step: an app is just one `.html`
+file.
+
+## Creating a mini app
+
+1. Write a single `.html` file (assets may be inlined; see limitations below).
+2. Import it in TatuChat:
+ - **From file:** open *Apps* → the **import** menu → *Import from file* and
+ pick your `.html` file.
+ - **From URL:** *Apps* → import menu → *Import from URL* and paste a link to
+ the HTML document.
+3. Open the app from the *Installed* list. Use the reload button in the top bar
+ while developing.
+
+The available apps page (`/rooms/apps`) lists the built-in apps (`tasks`,
+`accountdata`) under *Installed* once you install them, plus every app you
+import.
+
+## Talking to TatuChat
+
+The host listens for `message` events. A channel app sends a request with
+`window.parent.postMessage`, and the host answers with the same `requestId`.
+Replies are delivered in an event with a `response` object of the form
+`{data, error}`.
+
+Request payload:
+
+```js
+{
+ api: 'fromMiniApp',
+ widgetId: '',
+ requestId: 'any-unique-string',
+ action: 'io.tatuchat.storage.load', // one of the actions below
+ data: { /* action-specific payload */ }
+}
+```
+
+Helper for your app (copy this into your HTML):
+
+```js
+const APP_ID = 'myapp'; // must match the app id
+const _pending = new Map();
+let _requestCounter = 0;
+
+function bridgeRequest(action, data) {
+ return new Promise(function (resolve, reject) {
+ const requestId = 'r' + (++_requestCounter);
+ _pending.set(requestId, { resolve: resolve, reject: reject });
+ window.parent.postMessage({
+ api: 'fromMiniApp',
+ widgetId: APP_ID,
+ requestId: requestId,
+ action: action,
+ data: data
+ }, '*');
+ });
+}
+
+window.addEventListener('message', function (event) {
+ const msg = event.data;
+ if (!msg || msg.api !== 'fromMiniApp' || !msg.requestId) return;
+ if (!_pending.has(msg.requestId)) return;
+ const pending = _pending.get(msg.requestId);
+ _pending.delete(msg.requestId);
+ const response = msg.response || {};
+ if (response.error) pending.reject(new Error(response.error));
+ else pending.resolve(response.data);
+});
+```
+
+## Available actions
+
+### `io.tatuchat.storage.load`
+
+Loads the app's own key-value store (persisted in the user's account data under
+`im.tatuchat.miniapps.data.`). Resolves with a plain object.
+
+```js
+const data = await bridgeRequest('io.tatuchat.storage.load');
+// data => { ... } (any JSON values previously saved)
+```
+
+### `io.tatuchat.storage.save`
+
+Replaces the whole store. `data` must be an object. Resolves with `null`.
+
+```js
+await bridgeRequest('io.tatuchat.storage.save', { tasks: [...], version: 1 });
+```
+
+> Note: this **replaces** the entire store; there is no per-key merge. Load,
+> mutate, then save.
+
+### `io.tatuchat.identity.get`
+
+Resolves with the signed-in user's profile:
+
+```js
+const info = await bridgeRequest('io.tatuchat.identity.get');
+// => {
+// userId: '@user:server',
+// displayName: 'Tatu',
+// avatarUrl: 'mxc://server/...',
+// avatarHttpUrl: 'https://.../download/...', // may require auth
+// avatarDataUri: 'data:image/png;base64,...', // always renderable
+// homeserver: 'https://server.example'
+// }
+```
+
+The `avatarDataUri` is a base64 data URI fetched by TatuChat with the user's
+credentials. **Use `avatarDataUri` for `
`** – the homeserver usually
+requires an authenticated media request that a plain `
` tag cannot send
+(this is what caused the `M_MISSING_TOKEN` error). Fall back to `avatarHttpUrl`
+only if `avatarDataUri` is absent.
+
+### `io.tatuchat.theme.get`
+
+Resolves with the color palette currently used by TatuChat, so your app can
+match the ecosystem (light/dark aware). Colors are `#AARRGGBB` hex strings
+suitable for CSS.
+
+```js
+const { palette } = await bridgeRequest('io.tatuchat.theme.get');
+// => {
+// brightness: 'light' | 'dark',
+// primary: '#FF00D084',
+// onPrimary: '#FF000000',
+// primaryContainer: '#FF...',
+// surface: '#FF...',
+// surfaceContainerLowest/Low/Container/High/Highest: '#FF...',
+// secondary..., tertiary..., error...,
+// outline, outlineVariant, background, onBackground, onSurface, ...
+// }
+document.documentElement.style.setProperty('--primary', palette.primary);
+```
+
+### `io.tatuchat.accountdata.list`
+
+Resolves with a map of **all** account data of the signed-in user as an object
+of `{ key: { ...content } }`.
+
+### `io.tatuchat.accountdata.get`
+
+`data: { key: 'org.example.something' }` resolves with `{ key, content }` where
+`content` is the stored JSON content (or `{}` if absent).
+
+### `io.tatuchat.accountdata.set`
+
+`data: { key: 'org.example.something', content: { ... } }` stores or replaces
+the content of that account data key. Resolves with `null`.
+
+## Caveats & limitations
+
+- **Distribution / installation:** apps imported from URL must be served with
+ permissive CORS headers (`Access-Control-Allow-Origin`) so the browser can
+ read them. On the web, TatuChat retries through a CORS proxy if you configure
+ one (setting `miniAppCorsProxy` in the app settings/config). If the server
+ blocks cross-origin reads, import the `.html` file manually instead.
+- **Size:** the HTML source is stored in the user's account data, where
+ individual events are limited to about **64 KB** (homeservers like matrix.org
+ enforce this). Keep mini apps small and inline the assets; or load heavy
+ content from a URL at runtime.
+- **Security:** the app runs in a sandboxed web view with `allow-scripts` /
+ `allow-forms` / `allow-same-origin`. It can only reach TatuChat through the
+ bridge actions above – it cannot access the user's rooms, messages or files.
+- **No unsolicited access:** data is only exchanged when the app explicitly
+ calls the bridge. The host never pushes data into the app on its own.
+- **Store is per app:** every app has its own `storage.load`/`save` namespace.
+ `accountdata.*` is the only way to share data between apps.
+- **`storage.save` overwrites:** there is no merge/patch semantics.
+- **Native platforms:** the HTML is rendered by the platform web view; take
+ care with features that differ between browsers (e.g. storage APIs inside the
+ sandbox, `window.parent` assumptions). Prefer the bridge over `localStorage`.
\ No newline at end of file
diff --git a/lib/config/miniapp_config.dart b/lib/config/miniapp_config.dart
new file mode 100644
index 0000000..287b9b3
--- /dev/null
+++ b/lib/config/miniapp_config.dart
@@ -0,0 +1,127 @@
+// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
+// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import 'package:material_ui/material_ui.dart';
+import 'package:matrix/matrix.dart';
+import 'package:tatchat/l10n/l10n.dart';
+import 'package:tatchat/utils/miniapp_store.dart';
+
+/// ID of the built-in tasks example app.
+const String miniAppTasksId = 'tasks';
+
+/// ID of the built-in account data explorer app.
+const String miniAppAccountDataId = 'accountdata';
+
+/// Static registry of all built-in mini apps that ship with this build. A mini
+/// app only shows up in the "Apps" page once the user installs it (see
+/// [installedMiniApps]). Additionally to this list, users can import their own
+/// single-file HTML apps which are resolved through [resolveMiniAppConfig].
+const List availableMiniApps = [
+ MiniAppConfig(
+ id: miniAppTasksId,
+ icon: Icons.checklist_rounded,
+ color: Color(0xFF00D084),
+ htmlAsset: 'assets/miniapps/tasks.html',
+ ),
+ MiniAppConfig(
+ id: miniAppAccountDataId,
+ icon: Icons.storage_rounded,
+ color: Color(0xFF1F8EF1),
+ htmlAsset: 'assets/miniapps/accountdata.html',
+ ),
+];
+
+/// Declarative description of a mini app. The rendering is either a
+/// self-contained HTML document loaded from [htmlAsset] (built-in apps) or
+/// from [html] which is stored in the user's account data (imported apps).
+class MiniAppConfig {
+ final String id;
+ final IconData icon;
+ final Color color;
+ final String? name;
+ final String? htmlAsset;
+ final String? html;
+
+ const MiniAppConfig({
+ required this.id,
+ required this.icon,
+ required this.color,
+ this.name,
+ this.htmlAsset,
+ this.html,
+ });
+
+ /// Config for a user-imported app whose HTML source is persisted in the
+ /// account data store.
+ MiniAppConfig.custom({
+ required String id,
+ String? name,
+ String? html,
+ }) : this(
+ id: id,
+ icon: Icons.web_asset_rounded,
+ color: const Color(0xFF9C27B0),
+ name: name,
+ html: html,
+ );
+
+ bool get isCustom => html != null || htmlAsset == null;
+
+ /// Resolves the HTML source that should be rendered for this app. Returns a
+ /// bundled asset path for built-in apps or the raw HTML for imported apps.
+ String? get htmlSource => html ?? htmlAsset;
+}
+
+/// Resolves the config of a single installed app, whether it is built-in or
+/// user-imported. Returns null if the app is not installed or not available.
+MiniAppConfig? resolveMiniAppConfig(Client client, String appId) {
+ for (final app in availableMiniApps) {
+ if (app.id == appId) return app;
+ }
+ if (client.isMiniAppInstalled(appId)) {
+ final html = client.getMiniAppSource(appId);
+ if (html != null) {
+ return MiniAppConfig.custom(
+ id: appId,
+ name: client.getMiniAppName(appId),
+ html: html,
+ );
+ }
+ }
+ return null;
+}
+
+/// Returns the configs of all installed apps: built-in ones plus the
+/// user-imported ones. The visual order is: installed built-in apps first,
+/// then imported apps.
+List installedMiniAppConfigs(Client client) {
+ final installed = client.installedMiniApps;
+ return [
+ for (final app in availableMiniApps)
+ if (installed.contains(app.id)) app,
+ for (final id in installed)
+ if (!availableMiniApps.any((app) => app.id == id))
+ if (client.getMiniAppSource(id) != null)
+ MiniAppConfig.custom(
+ id: id,
+ name: client.getMiniAppName(id),
+ html: client.getMiniAppSource(id),
+ ),
+ ];
+}
+
+/// Resolves the localized display name of a mini app configuration.
+String miniAppName(BuildContext context, MiniAppConfig app) {
+ final customName = app.name;
+ if (customName != null && customName.isNotEmpty) return customName;
+ switch (app.id) {
+ case miniAppTasksId:
+ return L10n.of(context).tasks;
+ case miniAppAccountDataId:
+ return L10n.of(context).accountDataEditor;
+ default:
+ return app.id;
+ }
+}
\ No newline at end of file
diff --git a/lib/config/routes.dart b/lib/config/routes.dart
index 079ecea..321664f 100644
--- a/lib/config/routes.dart
+++ b/lib/config/routes.dart
@@ -6,6 +6,8 @@
import 'dart:async';
import 'package:tatchat/config/themes.dart';
+import 'package:tatchat/pages/apps/apps.dart';
+import 'package:tatchat/pages/apps/miniapp_page.dart';
import 'package:tatchat/pages/archive/archive.dart';
import 'package:tatchat/pages/bootstrap/bootstrap_page.dart';
import 'package:tatchat/pages/chat/chat.dart';
@@ -241,6 +243,23 @@ abstract class AppRoutes {
),
redirect: loggedOutRedirect,
),
+ GoRoute(
+ path: 'apps',
+ pageBuilder: (context, state) =>
+ defaultPageBuilder(context, state, const Apps()),
+ routes: [
+ GoRoute(
+ path: ':appId',
+ pageBuilder: (context, state) => defaultPageBuilder(
+ context,
+ state,
+ MiniAppPage(appId: state.pathParameters['appId']!),
+ ),
+ redirect: loggedOutRedirect,
+ ),
+ ],
+ redirect: loggedOutRedirect,
+ ),
ShellRoute(
pageBuilder: (context, state, child) => defaultPageBuilder(
context,
diff --git a/lib/config/setting_keys.dart b/lib/config/setting_keys.dart
index 22bef06..20dd44e 100644
--- a/lib/config/setting_keys.dart
+++ b/lib/config/setting_keys.dart
@@ -6,12 +6,12 @@
import 'dart:convert';
import 'package:async/async.dart';
-import 'package:tatchat/utils/platform_infos.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:managed_configurations/managed_configurations.dart';
import 'package:matrix/matrix_api_lite/utils/logs.dart';
import 'package:shared_preferences/shared_preferences.dart';
+import 'package:tatchat/utils/platform_infos.dart';
enum AppSettings {
textMessageMaxLength('textMessageMaxLength', 16384),
@@ -87,7 +87,11 @@ enum AppSettings {
benchmarksInLogs('chat.fluffy.benchmarks_in_logs', false),
autoSendErrorReports('chat.fluffy.auto_send_eror_reports', null),
knownErrorHashes>('chat.fluffy.known_crash_hashes', []),
- customLiveKitInstance('chat.fluffy.custom_live_kit_instance', '');
+ customLiveKitInstance('chat.fluffy.custom_live_kit_instance', ''),
+ /// URL of a CORS proxy used as a fallback when importing a mini app from a
+ /// URL on web, if the target server does not send CORS headers. Should end
+ /// with `?url=` or `?` (the target URL gets appended). Empty means disabled.
+ miniAppCorsProxy('chat.fluffy.miniapp_cors_proxy', '');
final String key;
final T _defaultValue;
diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb
index 586271b..78726cd 100644
--- a/lib/l10n/intl_en.arb
+++ b/lib/l10n/intl_en.arb
@@ -2359,6 +2359,7 @@
},
"appWantsToUseForLoginDescription": "You hereby allow the app and website to share information about you.",
"open": "Open",
+ "refresh": "Refresh",
"waitingForServer": "Waiting for server...",
"newChatRequest": "📩 New chat request",
"contentNotificationSettings": "Content notification settings",
@@ -2923,5 +2924,75 @@
"areYouSureDeletePost": "Delete",
"@areYouSureDeletePost": {
"description": "Delete this post from the mural"
+ },
+ "apps": "Apps",
+ "@apps": {
+ "type": "String",
+ "placeholders": {}
+ },
+ "tasks": "Tasks",
+ "@tasks": {
+ "type": "String",
+ "placeholders": {}
+ },
+ "accountDataEditor": "Account data",
+ "@accountDataEditor": {
+ "type": "String",
+ "placeholders": {}
+ },
+ "addTask": "Add task",
+ "@addTask": {
+ "type": "String",
+ "placeholders": {}
+ },
+ "taskInputPlaceholder": "What do you need to do?",
+ "@taskInputPlaceholder": {
+ "type": "String",
+ "placeholders": {}
+ },
+ "noTasksYet": "No tasks yet",
+ "@noTasksYet": {
+ "type": "String",
+ "placeholders": {}
+ },
+ "install": "Install",
+ "@install": {
+ "type": "String",
+ "placeholders": {}
+ },
+ "uninstall": "Uninstall",
+ "@uninstall": {
+ "type": "String",
+ "placeholders": {}
+ },
+ "installedApps": "Installed apps",
+ "@installedApps": {
+ "type": "String",
+ "placeholders": {}
+ },
+ "availableApps": "Available apps",
+ "@availableApps": {
+ "type": "String",
+ "placeholders": {}
+ },
+ "importApp": "Import app",
+ "@importApp": {
+ "type": "String",
+ "placeholders": {}
+ },
+ "importFromFile": "Import from file",
+ "@importFromFile": {
+ "type": "String",
+ "placeholders": {}
+ },
+ "importFromUrl": "Import from URL",
+ "@importFromUrl": {
+ "type": "String",
+ "placeholders": {}
+ },
+ "appImported": "App imported",
+ "@appImported": {
+ "type": "String",
+ "placeholders": {}
}
}
diff --git a/lib/pages/apps/apps.dart b/lib/pages/apps/apps.dart
new file mode 100644
index 0000000..e722f61
--- /dev/null
+++ b/lib/pages/apps/apps.dart
@@ -0,0 +1,130 @@
+// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
+// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import 'dart:convert';
+
+import 'package:file_picker/file_picker.dart';
+import 'package:flutter/foundation.dart';
+import 'package:http/http.dart' as http;
+import 'package:material_ui/material_ui.dart';
+import 'package:matrix/matrix.dart';
+import 'package:tatchat/config/setting_keys.dart';
+import 'package:tatchat/utils/miniapp_store.dart';
+import 'package:tatchat/widgets/matrix.dart';
+
+import 'apps_view.dart';
+
+class Apps extends StatefulWidget {
+ const Apps({super.key});
+
+ @override
+ AppsController createState() => AppsController();
+}
+
+class AppsController extends State {
+ Client get client => Matrix.of(context).client;
+
+ Future installMiniApp(String appId) async {
+ await client.installMiniApp(appId);
+ if (!mounted) return;
+ setState(() {});
+ }
+
+ Future uninstallMiniApp(String appId) async {
+ await client.uninstallMiniApp(appId);
+ if (!mounted) return;
+ setState(() {});
+ }
+
+ /// Lets the user pick a `.html` file from their device and imports it as a
+ /// custom mini app.
+ Future importMiniAppFromFile() async {
+ final file = await FilePicker.pickFile(
+ type: FileType.custom,
+ allowedExtensions: ['html', 'htm'],
+ );
+ if (file == null) return;
+ final name = file.name.replaceAll(RegExp(r'\.(html?|htm)$'), '');
+ final bytes = await file.readAsBytes();
+ await _importHtml(
+ name: name.isEmpty ? 'Imported app' : name,
+ html: _decodeBytes(bytes),
+ );
+ if (!mounted) return;
+ setState(() {});
+ }
+
+ /// Fetches an HTML document from [url] and imports it as a custom mini app.
+ ///
+ /// On web, the browser enforces CORS, so fetching a page that does not send
+ /// `Access-Control-Allow-Origin` fails with a `ClientException`. In that case
+ /// we transparently retry through a CORS proxy (if configured) so the import
+ /// still works.
+ Future importMiniAppFromUrl(String url) async {
+ if (url.trim().isEmpty) return;
+ final response = await _fetchViaCorsProxy(Uri.parse(url));
+ if (response.statusCode != 200) {
+ throw Exception('${response.statusCode} ${response.reasonPhrase}');
+ }
+ var name = Uri.parse(url).pathSegments.isNotEmpty
+ ? Uri.parse(url).pathSegments.last.replaceAll(
+ RegExp(r'\.(html?|htm)$'),
+ '',
+ )
+ : url;
+ if (name.isEmpty) name = 'Imported app';
+ await _importHtml(name: name, html: response.body);
+ if (!mounted) return;
+ setState(() {});
+ }
+
+ /// Fetches [url]. On web, if the initial request fails (usually because the
+ /// target server lacks CORS headers), retries through the configured CORS
+ /// proxy. Returns the successful response or rethrows the last error.
+ Future _fetchViaCorsProxy(Uri uri) async {
+ try {
+ final response = await http.get(uri);
+ if (response.statusCode == 200) return response;
+ // Non-200 (e.g. 404): fall through to the proxy only for CORS errors.
+ if (!kIsWeb) return response;
+ } catch (e) {
+ if (!kIsWeb) rethrow;
+ Logs().w('Direct download failed, trying CORS proxy', e);
+ }
+ final proxy = AppSettings.miniAppCorsProxy.value;
+ if (kIsWeb && proxy.isNotEmpty) {
+ final separator = proxy.contains('?') ? '&' : '?';
+ final proxied = await http.get(Uri.parse('$proxy$separator$uri'));
+ if (proxied.statusCode == 200) return proxied;
+ throw Exception('${proxied.statusCode} ${proxied.reasonPhrase}');
+ }
+ throw Exception(
+ 'The server does not allow cross-origin requests (CORS). '
+ 'Import the file manually or set a CORS proxy in the settings.',
+ );
+ }
+
+ String _decodeBytes(List bytes) {
+ for (final encoding in const [utf8, latin1]) {
+ try {
+ return encoding.decode(bytes);
+ } catch (_) {}
+ }
+ return latin1.decode(bytes, allowInvalid: true);
+ }
+
+ Future _importHtml({
+ required String name,
+ required String html,
+ }) =>
+ client.importMiniApp(
+ appId: 'imported-${DateTime.now().millisecondsSinceEpoch}',
+ name: name,
+ html: html,
+ );
+
+ @override
+ Widget build(BuildContext context) => AppsView(this);
+}
\ No newline at end of file
diff --git a/lib/pages/apps/apps_view.dart b/lib/pages/apps/apps_view.dart
new file mode 100644
index 0000000..0f0044c
--- /dev/null
+++ b/lib/pages/apps/apps_view.dart
@@ -0,0 +1,178 @@
+// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
+// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import 'package:go_router/go_router.dart';
+import 'package:material_ui/material_ui.dart';
+import 'package:matrix/matrix.dart';
+import 'package:tatchat/config/miniapp_config.dart';
+import 'package:tatchat/l10n/l10n.dart';
+import 'package:tatchat/pages/apps/apps.dart';
+import 'package:tatchat/utils/miniapp_store.dart';
+import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart';
+import 'package:tatchat/widgets/layouts/max_width_body.dart';
+
+class AppsView extends StatelessWidget {
+ final AppsController controller;
+
+ const AppsView(this.controller, {super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final installed = controller.client.installedMiniApps;
+ final installedApps = installedMiniAppConfigs(controller.client);
+ final availableApps = availableMiniApps
+ .where((app) => !installed.contains(app.id))
+ .toList();
+ return Scaffold(
+ appBar: AppBar(
+ leading: const Center(child: BackButton()),
+ title: Text(L10n.of(context).apps),
+ actions: [
+ PopupMenuButton<_ImportAction>(
+ icon: const Icon(Icons.add_box_outlined),
+ tooltip: L10n.of(context).importApp,
+ onSelected: (action) async {
+ switch (action) {
+ case _ImportAction.file:
+ await controller.importMiniAppFromFile();
+ case _ImportAction.url:
+ final url = await showTextInputDialog(
+ context: context,
+ title: L10n.of(context).importFromUrl,
+ okLabel: L10n.of(context).importApp,
+ );
+ if (url == null || url.trim().isEmpty) break;
+ try {
+ await controller.importMiniAppFromUrl(url);
+ if (!context.mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(L10n.of(context).appImported)),
+ );
+ } catch (e, s) {
+ Logs().e('Failed to import mini app', e, s);
+ if (!context.mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(e.toString())),
+ );
+ }
+ }
+ },
+ itemBuilder: (context) => [
+ PopupMenuItem(
+ value: _ImportAction.file,
+ child: Row(
+ children: [
+ const Icon(Icons.upload_file_outlined),
+ const SizedBox(width: 12),
+ Text(L10n.of(context).importFromFile),
+ ],
+ ),
+ ),
+ PopupMenuItem(
+ value: _ImportAction.url,
+ child: Row(
+ children: [
+ const Icon(Icons.link),
+ const SizedBox(width: 12),
+ Text(L10n.of(context).importFromUrl),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ body: MaxWidthBody(
+ withScrolling: false,
+ child: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ if (installedApps.isNotEmpty) ...[
+ _SectionHeader(L10n.of(context).installedApps),
+ for (final app in installedApps)
+ _AppCard(
+ app: app,
+ onTap: () => context.go('/rooms/apps/${app.id}'),
+ trailing: [
+ IconButton(
+ icon: const Icon(Icons.open_in_new),
+ tooltip: L10n.of(context).open,
+ onPressed: () => context.go('/rooms/apps/${app.id}'),
+ ),
+ IconButton(
+ icon: const Icon(Icons.delete_outline),
+ tooltip: L10n.of(context).uninstall,
+ onPressed: () => controller.uninstallMiniApp(app.id),
+ ),
+ ],
+ ),
+ ],
+ if (availableApps.isNotEmpty) ...[
+ _SectionHeader(L10n.of(context).availableApps),
+ for (final app in availableApps)
+ _AppCard(
+ app: app,
+ onTap: null,
+ trailing: [
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ child: TextButton(
+ onPressed: () => controller.installMiniApp(app.id),
+ child: Text(L10n.of(context).install),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+enum _ImportAction {
+ file,
+ url,
+}
+
+class _SectionHeader extends StatelessWidget {
+ final String text;
+
+ const _SectionHeader(this.text);
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.only(top: 8, bottom: 4),
+ child: Text(text, style: Theme.of(context).textTheme.titleSmall),
+ );
+ }
+}
+
+class _AppCard extends StatelessWidget {
+ final MiniAppConfig app;
+ final VoidCallback? onTap;
+ final List trailing;
+
+ const _AppCard({required this.app, required this.trailing, this.onTap});
+
+ @override
+ Widget build(BuildContext context) {
+ return Card(
+ margin: const EdgeInsets.symmetric(vertical: 4),
+ child: ListTile(
+ onTap: onTap,
+ leading: CircleAvatar(
+ backgroundColor: app.color.withValues(alpha: .15),
+ foregroundColor: app.color,
+ child: Icon(app.icon),
+ ),
+ title: Text(miniAppName(context, app)),
+ trailing: Row(mainAxisSize: MainAxisSize.min, children: trailing),
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/pages/apps/miniapp_page.dart b/lib/pages/apps/miniapp_page.dart
new file mode 100644
index 0000000..2b32309
--- /dev/null
+++ b/lib/pages/apps/miniapp_page.dart
@@ -0,0 +1,112 @@
+// SPDX-FileCopyrightText: 2026 TatuChat
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import 'package:flutter/services.dart';
+import 'package:material_ui/material_ui.dart';
+import 'package:matrix/matrix.dart';
+import 'package:tatchat/config/miniapp_config.dart';
+import 'package:tatchat/utils/miniapp_bridge.dart';
+import 'package:tatchat/widgets/matrix.dart';
+
+import 'miniapp_shell_stub.dart'
+ if (dart.library.js_interop) 'miniapp_shell_web.dart'
+ if (dart.library.ffi) 'miniapp_shell_native.dart';
+
+/// Generic page that renders a mini app. The mini app is a self-contained HTML
+/// document that talks to the TatuChat client through the [MiniappBridge]
+/// protocol (see `assets/miniapps/`).
+class MiniAppPage extends StatefulWidget {
+ final String appId;
+
+ const MiniAppPage({super.key, required this.appId});
+
+ @override
+ State createState() => _MiniAppPageState();
+}
+
+class _MiniAppPageState extends State {
+ Client get client => Matrix.of(context).client;
+
+ MiniAppConfig? _config;
+ String? _html;
+ bool _notFound = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _load();
+ }
+
+ Future _load() async {
+ final config = resolveMiniAppConfig(client, widget.appId);
+ if (config == null) {
+ if (!mounted) return;
+ setState(() => _notFound = true);
+ return;
+ }
+ if (config.htmlAsset != null) {
+ final html = await rootBundle.loadString(config.htmlAsset!);
+ if (!mounted) return;
+ setState(() {
+ _config = config;
+ _html = html;
+ });
+ return;
+ }
+ // Imported app: the HTML source lives in the account data store.
+ if (!mounted) return;
+ setState(() {
+ _config = config;
+ _html = config.html;
+ });
+ }
+
+ /// Entry point of the bridge: called by the web view/iframe with a request
+ /// coming from the mini app's JavaScript.
+ Future