feat: mini apps customizados com importacao por arquivo/URL, paleta de cores, avatar autenticado e documentacao
This commit is contained in:
218
lib/utils/miniapp_bridge.dart
Normal file
218
lib/utils/miniapp_bridge.dart
Normal file
@@ -0,0 +1,218 @@
|
||||
// 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
|
||||
/// `#AARRGGBB` hex strings 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();
|
||||
return '#${value.toRadixString(16).padLeft(8, '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};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user