Files
tatuchat/lib/utils/miniapp_bridge.dart
Welton Moura 6c689d1f34
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
feat: tela de apps com logos/menu de exclusao, correcao de tema nas mini apps e sidebar de navegacao
2026-09-19 00:08:35 -03:00

227 lines
7.7 KiB
Dart

// SPDX-FileCopyrightText: 2026 TatuChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import 'package:tatchat/utils/miniapp_store.dart';
/// Wire protocol between an HTML mini app and the TatuChat client.
///
/// The mini app sends a request object via `window.parent.postMessage`:
/// ```
/// {
/// "api": "fromMiniApp",
/// "widgetId": "<appId>",
/// "requestId": "...",
/// "action": "<action>",
/// "data": <any>
/// }
/// ```
/// The host answers with `{api, widgetId, requestId, response: {data, error}}`.
class MiniappBridge {
/// No-op instantiation guard; this class only holds static helpers.
MiniappBridge._();
static const String api = 'fromMiniApp';
static const String actionLoad = 'io.tatuchat.storage.load';
static const String actionSave = 'io.tatuchat.storage.save';
static const String actionIdentity = 'io.tatuchat.identity.get';
static const String actionTheme = 'io.tatuchat.theme.get';
static const String actionAccountDataList = 'io.tatuchat.accountdata.list';
static const String actionAccountDataGet = 'io.tatuchat.accountdata.get';
static const String actionAccountDataSet = 'io.tatuchat.accountdata.set';
/// Handles one request coming from a mini app and returns the payload that
/// gets sent back inside `response.data`, or `response.error` on failures.
static Future<Map<String, Object?>> handleRequest(
Client client,
String appId,
Map<String, dynamic> request, {
Map<String, Object?>? palette,
}) async {
final action = request['action'];
final data = request['data'];
switch (action) {
case actionLoad:
return {'data': client.getMiniAppData(appId)};
case actionSave:
return _handleSave(client, appId, data);
case actionIdentity:
return _handleIdentity(client);
case actionTheme:
return {
'data': {
'palette': palette ?? const {},
'brightness': palette?['brightness'] ?? 'light',
},
};
case actionAccountDataList:
return _handleAccountDataList(client);
case actionAccountDataGet:
return _handleAccountDataGet(client, data);
case actionAccountDataSet:
return _handleAccountDataSet(client, data);
default:
return const {'error': 'UNKNOWN_ACTION'};
}
}
/// Builds the theme palette map sent to mini apps. Colors are serialized as
/// `#RRGGBBAA` hex strings (CSS 8-digit order) so they can be used directly
/// in CSS/JS.
static Map<String, Object?> buildPalette(
ColorScheme scheme,
Brightness brightness,
) {
String hex(Color c) {
final value = c.toARGB32();
final rgb = value & 0xFFFFFF;
final alpha = (value >> 24) & 0xFF;
return '#${rgb
.toRadixString(16)
.padLeft(6, '0')}${alpha.toRadixString(16).padLeft(2, '0')}'
.toUpperCase();
}
return {
'brightness': brightness == Brightness.dark ? 'dark' : 'light',
'primary': hex(scheme.primary),
'onPrimary': hex(scheme.onPrimary),
'primaryContainer': hex(scheme.primaryContainer),
'onPrimaryContainer': hex(scheme.onPrimaryContainer),
'secondary': hex(scheme.secondary),
'onSecondary': hex(scheme.onSecondary),
'secondaryContainer': hex(scheme.secondaryContainer),
'onSecondaryContainer': hex(scheme.onSecondaryContainer),
'tertiary': hex(scheme.tertiary),
'onTertiary': hex(scheme.onTertiary),
'tertiaryContainer': hex(scheme.tertiaryContainer),
'onTertiaryContainer': hex(scheme.onTertiaryContainer),
'background': hex(scheme.surface),
'onBackground': hex(scheme.onSurface),
'surface': hex(scheme.surface),
'onSurface': hex(scheme.onSurface),
'surfaceVariant': hex(scheme.surfaceContainerHighest),
'onSurfaceVariant': hex(scheme.onSurfaceVariant),
'surfaceContainerLowest': hex(scheme.surfaceContainerLowest),
'surfaceContainerLow': hex(scheme.surfaceContainerLow),
'surfaceContainer': hex(scheme.surfaceContainer),
'surfaceContainerHigh': hex(scheme.surfaceContainerHigh),
'surfaceContainerHighest': hex(scheme.surfaceContainerHighest),
'outline': hex(scheme.outline),
'outlineVariant': hex(scheme.outlineVariant),
'error': hex(scheme.error),
'onError': hex(scheme.onError),
'errorContainer': hex(scheme.errorContainer),
'onErrorContainer': hex(scheme.onErrorContainer),
'inverseSurface': hex(scheme.inverseSurface),
'onInverseSurface': hex(scheme.onInverseSurface),
'shadow': hex(scheme.shadow),
'scrim': hex(scheme.scrim),
};
}
static Future<Map<String, Object?>> _handleSave(
Client client,
String appId,
dynamic data,
) async {
if (data is Map) {
await client.setMiniAppData(
appId,
Map<String, Object?>.from(data.map((k, v) => MapEntry('$k', v))),
);
return const {'data': null};
}
return const {'error': 'INVALID_DATA'};
}
static Future<Map<String, Object?>> _handleIdentity(Client client) async {
final profile = await client.fetchOwnProfile();
final avatarUrl = profile.avatarUrl;
String? avatarDataUri;
if (avatarUrl != null) {
final downloadUri = await avatarUrl.getDownloadUri(
client,
skipScanner: true,
);
if (downloadUri != Uri()) {
try {
final request = http.Request('GET', downloadUri);
if (client.accessToken != null) {
request.headers['authorization'] = 'Bearer ${client.accessToken}';
}
final streamed = await client.httpClient.send(request);
final response = await http.Response.fromStream(streamed);
if (response.statusCode >= 200 && response.statusCode < 300) {
final mimeType = response.headers['content-type']?.split(';').first ??
'application/octet-stream';
avatarDataUri =
'data:$mimeType;base64,${base64Encode(response.bodyBytes)}';
}
} catch (e) {
Logs().d('Unable to download avatar for mini app', e);
}
}
}
return {
'data': {
'userId': client.userID?.toString(),
'displayName':
profile.displayName ?? client.userID?.localpart.toString(),
'avatarUrl': avatarUrl?.toString(),
'avatarHttpUrl': avatarUrl == null
? null
: (await avatarUrl.getDownloadUri(
client,
skipScanner: true,
))
.toString(),
'avatarDataUri': avatarDataUri,
'homeserver': client.homeserver?.toString(),
},
};
}
static Map<String, Object?> _handleAccountDataList(Client client) {
return {
'data': client.accountData.map(
(key, event) => MapEntry(key, event.content),
),
};
}
static Map<String, Object?> _handleAccountDataGet(
Client client,
dynamic data,
) {
final key = data is Map ? data['key'] : null;
if (key is! String) return const {'error': 'INVALID_KEY'};
final event = client.accountData[key];
return {
'data': {'key': key, 'content': event?.content ?? const {}},
};
}
static Future<Map<String, Object?>> _handleAccountDataSet(
Client client,
dynamic data,
) async {
if (data is! Map) return const {'error': 'INVALID_DATA'};
final key = data['key'];
final content = data['content'];
if (key is! String || content is! Map) {
return const {'error': 'INVALID_DATA'};
}
await client.setAccountData(
client.userID!,
key,
Map<String, Object?>.from(content.map((k, v) => MapEntry('$k', v))),
);
return const {'data': null};
}
}