feat: tela de apps com logos/menu de exclusao, correcao de tema nas mini apps e sidebar de navegacao
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

This commit is contained in:
2026-09-19 00:08:35 -03:00
parent 1f692b1e4e
commit 6c689d1f34
23 changed files with 1039 additions and 114 deletions

View File

@@ -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
View 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',
);
}

View File

@@ -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;
}
}