feat: tela de apps com logos/menu de exclusao, correcao de tema nas mini apps e sidebar de navegacao
This commit is contained in:
@@ -71,11 +71,20 @@ class MiniappBridge {
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// `#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();
|
||||
return '#${value.toRadixString(16).padLeft(8, '0').toUpperCase()}';
|
||||
final rgb = value & 0xFFFFFF;
|
||||
final alpha = (value >> 24) & 0xFF;
|
||||
return '#${rgb
|
||||
.toRadixString(16)
|
||||
.padLeft(6, '0')}${alpha.toRadixString(16).padLeft(2, '0')}'
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
109
lib/utils/miniapp_meta.dart
Normal file
109
lib/utils/miniapp_meta.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
// SPDX-FileCopyrightText: 2026 TatuChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
/// Names of the `<meta>` tags a mini app document must declare to be
|
||||
/// installable. They act as the mini app's manifest.
|
||||
const String miniAppMetaIdTag = 'mini-app-id';
|
||||
const String miniAppMetaNameTag = 'mini-app-name';
|
||||
const String miniAppMetaCategoryTag = 'mini-app-category';
|
||||
const String miniAppMetaDescriptionTag = 'mini-app-description';
|
||||
const String miniAppMetaLogoTag = 'mini-app-logo';
|
||||
const String miniAppMetaInChatTag = 'mini-app-in-chat';
|
||||
|
||||
const List<String> miniAppMetaTags = [
|
||||
miniAppMetaIdTag,
|
||||
miniAppMetaNameTag,
|
||||
miniAppMetaCategoryTag,
|
||||
miniAppMetaDescriptionTag,
|
||||
miniAppMetaLogoTag,
|
||||
miniAppMetaInChatTag,
|
||||
];
|
||||
|
||||
/// Manifest of a mini app, extracted from its `<meta>` tags.
|
||||
///
|
||||
/// [id] is mandatory and non-empty. It is used as the account data key under
|
||||
/// which the app's store and HTML source are persisted
|
||||
/// (`im.tatuchat.miniapps.data.<id>` / `im.tatuchat.miniapps.source.<id>`).
|
||||
class MiniAppMeta {
|
||||
final String id;
|
||||
final String name;
|
||||
final String category;
|
||||
final String description;
|
||||
final String logo;
|
||||
|
||||
/// Whether the mini app can run inside a chat room. `false` by default.
|
||||
final bool inChat;
|
||||
|
||||
const MiniAppMeta({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.category = '',
|
||||
this.description = '',
|
||||
this.logo = '',
|
||||
this.inChat = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Extracts the meta tags of an HTML document into a `name -> content` map
|
||||
/// (names normalized to lower case). Attribute order and quoting style are
|
||||
/// irrelevant.
|
||||
Map<String, String> extractMetaTags(String html) {
|
||||
final result = <String, String>{};
|
||||
final metaRegex = RegExp(r'<meta\b[^>]*>', caseSensitive: false);
|
||||
final attrRegex = RegExp(
|
||||
r"""(name|content)\s*=\s*["']([^"']*)["']""",
|
||||
caseSensitive: false,
|
||||
);
|
||||
for (final match in metaRegex.allMatches(html)) {
|
||||
final attributes = <String, String>{};
|
||||
for (final attr in attrRegex.allMatches(match.group(0)!)) {
|
||||
attributes[attr.group(1)!.toLowerCase()] = attr.group(2)!;
|
||||
}
|
||||
final name = attributes['name'];
|
||||
final content = attributes['content'];
|
||||
if (name != null && content != null) {
|
||||
result[name.toLowerCase()] = content;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Parses the mini app manifest out of the `<meta>` tags of [html].
|
||||
///
|
||||
/// All [miniAppMetaTags] must be present (their content may be empty), except
|
||||
/// `mini-app-id` and `mini-app-name`, which must also be non-empty. Throws a
|
||||
/// [FormatException] with a human readable reason otherwise.
|
||||
MiniAppMeta parseMiniAppMeta(String html) {
|
||||
final tags = extractMetaTags(html);
|
||||
final missing = [
|
||||
for (final tag in miniAppMetaTags)
|
||||
if (!tags.containsKey(tag)) tag,
|
||||
];
|
||||
if (missing.isNotEmpty) {
|
||||
throw FormatException(
|
||||
'This document is missing the required meta tag(s): '
|
||||
'${missing.join(', ')}',
|
||||
);
|
||||
}
|
||||
final id = tags[miniAppMetaIdTag]!.trim();
|
||||
final name = tags[miniAppMetaNameTag]!.trim();
|
||||
if (id.isEmpty) {
|
||||
throw FormatException(
|
||||
'The meta tag "$miniAppMetaIdTag" must not be empty',
|
||||
);
|
||||
}
|
||||
if (name.isEmpty) {
|
||||
throw FormatException(
|
||||
'The meta tag "$miniAppMetaNameTag" must not be empty',
|
||||
);
|
||||
}
|
||||
return MiniAppMeta(
|
||||
id: id,
|
||||
name: name,
|
||||
category: tags[miniAppMetaCategoryTag]!.trim(),
|
||||
description: tags[miniAppMetaDescriptionTag]!.trim(),
|
||||
logo: tags[miniAppMetaLogoTag]!.trim(),
|
||||
inChat: tags[miniAppMetaInChatTag]!.trim().toLowerCase() == 'true',
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:tatchat/utils/miniapp_meta.dart';
|
||||
|
||||
/// Account data key that holds the list of installed mini app IDs.
|
||||
const String installedMiniAppsKey = 'im.tatuchat.miniapps.installed';
|
||||
@@ -32,16 +33,22 @@ extension MiniAppStoreExtension on Client {
|
||||
if (apps.contains(appId)) return;
|
||||
apps.add(appId);
|
||||
await setAccountData(userID!, installedMiniAppsKey, {'apps': apps});
|
||||
_cacheAccountData(installedMiniAppsKey, {'apps': apps});
|
||||
}
|
||||
|
||||
Future<void> uninstallMiniApp(String appId) async {
|
||||
final apps = installedMiniApps..remove(appId);
|
||||
await setAccountData(userID!, installedMiniAppsKey, {'apps': apps});
|
||||
_cacheAccountData(installedMiniAppsKey, {'apps': apps});
|
||||
// Clear the custom HTML source if present.
|
||||
await setAccountData(userID!, '$miniAppSourceKeyPrefix$appId', {
|
||||
'html': '',
|
||||
'name': '',
|
||||
});
|
||||
_cacheAccountData(
|
||||
'$miniAppSourceKeyPrefix$appId',
|
||||
{'html': '', 'name': ''},
|
||||
);
|
||||
}
|
||||
|
||||
/// Reads the JSON document of the given mini app. Returns an empty map if
|
||||
@@ -50,29 +57,47 @@ extension MiniAppStoreExtension on Client {
|
||||
accountData['$miniAppDataKeyPrefix$appId']?.content ?? const {};
|
||||
|
||||
/// Overwrites the JSON document of the given mini app.
|
||||
Future<void> setMiniAppData(String appId, Map<String, Object?> data) =>
|
||||
setAccountData(userID!, '$miniAppDataKeyPrefix$appId', data);
|
||||
Future<void> setMiniAppData(String appId, Map<String, Object?> data) async {
|
||||
final key = '$miniAppDataKeyPrefix$appId';
|
||||
await setAccountData(userID!, key, data);
|
||||
_cacheAccountData(key, data);
|
||||
}
|
||||
|
||||
// ---------- Imported (custom) apps ----------
|
||||
|
||||
/// Stores the HTML source and a display name for a user-imported mini app
|
||||
/// and adds it to the installed list if not already present.
|
||||
/// Stores the HTML source and the manifest of a user-imported mini app and
|
||||
/// adds it to the installed list if not already present. The manifest's
|
||||
/// [MiniAppMeta.id] is used as the account data key for the app.
|
||||
Future<void> importMiniApp({
|
||||
required String appId,
|
||||
required String name,
|
||||
required MiniAppMeta meta,
|
||||
required String html,
|
||||
}) async {
|
||||
await setAccountData(userID!, '$miniAppSourceKeyPrefix$appId', {
|
||||
'name': name,
|
||||
final sourceKey = '$miniAppSourceKeyPrefix${meta.id}';
|
||||
final content = <String, Object?>{
|
||||
'name': meta.name,
|
||||
'category': meta.category,
|
||||
'description': meta.description,
|
||||
'logo': meta.logo,
|
||||
'inChat': meta.inChat,
|
||||
'html': html,
|
||||
});
|
||||
};
|
||||
await setAccountData(userID!, sourceKey, content);
|
||||
_cacheAccountData(sourceKey, content);
|
||||
final apps = installedMiniApps;
|
||||
if (!apps.contains(appId)) {
|
||||
apps.add(appId);
|
||||
if (!apps.contains(meta.id)) {
|
||||
apps.add(meta.id);
|
||||
await setAccountData(userID!, installedMiniAppsKey, {'apps': apps});
|
||||
_cacheAccountData(installedMiniAppsKey, {'apps': apps});
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors the just-written account data into the in-memory cache so that
|
||||
/// reads right after a write (e.g. a mini app saving and immediately
|
||||
/// loading its store) see the new value without waiting for the next sync.
|
||||
void _cacheAccountData(String key, Map<String, Object?> content) {
|
||||
accountData[key] = BasicEvent(type: key, content: content);
|
||||
}
|
||||
|
||||
/// Returns the raw HTML source of an imported mini app, or null if the app
|
||||
/// is a built-in app or was never imported.
|
||||
String? getMiniAppSource(String appId) {
|
||||
@@ -85,10 +110,36 @@ extension MiniAppStoreExtension on Client {
|
||||
|
||||
/// Returns the display name of an imported mini app, or null.
|
||||
String? getMiniAppName(String appId) {
|
||||
final content =
|
||||
accountData['$miniAppSourceKeyPrefix$appId']?.content;
|
||||
if (content == null) return null;
|
||||
final name = content['name'];
|
||||
return (name is String && name.isNotEmpty) ? name : null;
|
||||
final content = accountData['$miniAppSourceKeyPrefix$appId']?.content;
|
||||
return _stringContent(content, 'name');
|
||||
}
|
||||
|
||||
/// Returns the category of an imported mini app, or null.
|
||||
String? getMiniAppCategory(String appId) {
|
||||
final content = accountData['$miniAppSourceKeyPrefix$appId']?.content;
|
||||
return _stringContent(content, 'category');
|
||||
}
|
||||
|
||||
/// Returns the description of an imported mini app, or null.
|
||||
String? getMiniAppDescription(String appId) {
|
||||
final content = accountData['$miniAppSourceKeyPrefix$appId']?.content;
|
||||
return _stringContent(content, 'description');
|
||||
}
|
||||
|
||||
/// Returns the logo (data URI or URL) of an imported mini app, or null.
|
||||
String? getMiniAppLogo(String appId) {
|
||||
final content = accountData['$miniAppSourceKeyPrefix$appId']?.content;
|
||||
return _stringContent(content, 'logo');
|
||||
}
|
||||
|
||||
/// Whether an imported mini app declares that it can run inside a chat.
|
||||
bool getMiniAppInChat(String appId) {
|
||||
final content = accountData['$miniAppSourceKeyPrefix$appId']?.content;
|
||||
return content?['inChat'] == true;
|
||||
}
|
||||
|
||||
String? _stringContent(Map<String, Object?>? content, String key) {
|
||||
final value = content?[key];
|
||||
return (value is String && value.isNotEmpty) ? value : null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user