Major architecture update: stable identity, component validation, media indexing, and protocol docs
This commit is contained in:
1 parent
ce7c97d13a
commit
01c110bbe1
16 files changed
+2718
-468
No files matched your search
@@ -12,6 +12,14 @@ import '../utils/extensions.dart';
|
||||
const _defaultMessageLimit = 50;
|
||||
const _maxMessageLimit = 100;
|
||||
|
||||
const _defaultMediaLimit = 50;
|
||||
const _maxMediaLimit = 100;
|
||||
|
||||
const _maxComponentDepth = 6;
|
||||
const _maxComponentNodes = 250;
|
||||
const _maxMediaItemsPerComponent = 64;
|
||||
const _maxExternalUrlLength = 2048;
|
||||
|
||||
/// Get messages for a channel
|
||||
/// Query params: channelId (required), limit, before, after
|
||||
Future<Response> getMessagesHandler(Request req) async {
|
||||
@@ -33,8 +41,7 @@ Future<Response> getMessagesHandler(Request req) async {
|
||||
}
|
||||
|
||||
final channelId = int.tryParse(channelIdStr);
|
||||
final parsedLimit =
|
||||
limitStr == null ? _defaultMessageLimit : int.tryParse(limitStr);
|
||||
final parsedLimit = limitStr == null ? _defaultMessageLimit : int.tryParse(limitStr);
|
||||
int? before;
|
||||
int? after;
|
||||
|
||||
@@ -70,7 +77,6 @@ Future<Response> getMessagesHandler(Request req) async {
|
||||
}
|
||||
}
|
||||
|
||||
// Check channel exists
|
||||
final channel = channelBox.get(channelId);
|
||||
if (channel == null) {
|
||||
return Response.notFound(
|
||||
@@ -99,8 +105,7 @@ Future<Response> getMessagesHandler(Request req) async {
|
||||
query.close();
|
||||
|
||||
final hasMore = fetchedMessages.length > limit;
|
||||
final messages =
|
||||
hasMore ? fetchedMessages.sublist(0, limit) : fetchedMessages;
|
||||
final messages = hasMore ? fetchedMessages.sublist(0, limit) : fetchedMessages;
|
||||
|
||||
int? nextBefore;
|
||||
int? nextAfter;
|
||||
@@ -126,7 +131,87 @@ Future<Response> getMessagesHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
/// Send a message
|
||||
/// Get channel media index (for gallery grid view)
|
||||
Future<Response> getChannelMediaHandler(Request req) async {
|
||||
final userId = userIdProperty[req];
|
||||
if (userId == null) {
|
||||
return Response.unauthorized(
|
||||
body: Body.fromString('Not authenticated'),
|
||||
);
|
||||
}
|
||||
|
||||
final channelIdStr = req.rawPathParameters[#channelId];
|
||||
final limitStr = req.url.queryParameters['limit'];
|
||||
final beforeStr = req.url.queryParameters['before'];
|
||||
final kindFilter = req.url.queryParameters['kind']?.trim();
|
||||
|
||||
if (channelIdStr == null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('channelId required'),
|
||||
);
|
||||
}
|
||||
final channelId = int.tryParse(channelIdStr);
|
||||
if (channelId == null || channelId <= 0) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Invalid channelId'),
|
||||
);
|
||||
}
|
||||
|
||||
final channel = channelBox.get(channelId);
|
||||
if (channel == null) {
|
||||
return Response.notFound(
|
||||
body: Body.fromString('Channel not found'),
|
||||
);
|
||||
}
|
||||
|
||||
final parsedLimit = limitStr == null ? _defaultMediaLimit : int.tryParse(limitStr);
|
||||
if (parsedLimit == null || parsedLimit <= 0) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Invalid limit'),
|
||||
);
|
||||
}
|
||||
final limit = parsedLimit > _maxMediaLimit ? _maxMediaLimit : parsedLimit;
|
||||
|
||||
int? before;
|
||||
if (beforeStr != null) {
|
||||
before = int.tryParse(beforeStr);
|
||||
if (before == null || before <= 0) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('Invalid `before` cursor'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
var condition = ChatMessageMedia_.channelId.equals(channelId);
|
||||
if (before != null) {
|
||||
condition = condition & ChatMessageMedia_.id.lessThan(before);
|
||||
}
|
||||
if (kindFilter != null && kindFilter.isNotEmpty) {
|
||||
condition = condition & ChatMessageMedia_.kind.equals(kindFilter);
|
||||
}
|
||||
|
||||
final queryBuilder = messageMediaBox.query(condition)..order(ChatMessageMedia_.id, flags: Order.descending);
|
||||
final query = queryBuilder.build();
|
||||
query.limit = limit + 1;
|
||||
|
||||
final rows = query.find();
|
||||
query.close();
|
||||
|
||||
final hasMore = rows.length > limit;
|
||||
final page = hasMore ? rows.sublist(0, limit) : rows;
|
||||
final nextBefore = page.isNotEmpty ? page.last.id : null;
|
||||
|
||||
return Response.ok(
|
||||
body: {
|
||||
'channelId': channelId,
|
||||
'media': page.map(_mediaToMap).toList(),
|
||||
'limit': limit,
|
||||
'hasMore': hasMore,
|
||||
'nextBefore': nextBefore,
|
||||
}.toBody,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> sendMessageHandler(Request req) async {
|
||||
final userId = userIdProperty[req];
|
||||
|
||||
@@ -147,13 +232,19 @@ Future<Response> sendMessageHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
final validationError = _validateComponents(components);
|
||||
if (validationError != null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString(validationError),
|
||||
);
|
||||
}
|
||||
|
||||
if (channelId == null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString('channelId required'),
|
||||
);
|
||||
}
|
||||
|
||||
// Check channel exists
|
||||
final channel = channelBox.get(channelId);
|
||||
if (channel == null) {
|
||||
return Response.notFound(
|
||||
@@ -161,7 +252,6 @@ Future<Response> sendMessageHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
// Get user info for caching
|
||||
final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
|
||||
final user = userQuery.findFirst();
|
||||
userQuery.close();
|
||||
@@ -172,7 +262,8 @@ Future<Response> sendMessageHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
// Create message with cached author info
|
||||
final normalizedComponents = _normalizeComponentsForStorage(components);
|
||||
|
||||
final message = ChatMessage(
|
||||
channelId: channelId,
|
||||
authorLocalId: user.id,
|
||||
@@ -180,13 +271,14 @@ Future<Response> sendMessageHandler(Request req) async {
|
||||
authorUsername: user.username,
|
||||
authorAvatarUrl: user.avatarUrl,
|
||||
authorHomeServer: user.homeServer,
|
||||
content: _buildSearchContent(components, contentOverride),
|
||||
componentsJson: jsonEncode(components),
|
||||
content: _buildSearchContent(normalizedComponents, contentOverride),
|
||||
componentsJson: jsonEncode(normalizedComponents),
|
||||
);
|
||||
|
||||
final id = messageBox.put(message);
|
||||
message.id = id;
|
||||
_attachReferencedFiles(components);
|
||||
_attachReferencedFiles(normalizedComponents);
|
||||
_syncMessageMediaIndex(message, normalizedComponents);
|
||||
|
||||
broadcastMessageCreated(
|
||||
channelId: channelId,
|
||||
@@ -207,7 +299,6 @@ Future<Response> sendMessageHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
/// Edit a message
|
||||
Future<Response> editMessageHandler(Request req) async {
|
||||
final userId = userIdProperty[req];
|
||||
|
||||
@@ -238,7 +329,6 @@ Future<Response> editMessageHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
// Get user to verify ownership
|
||||
final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
|
||||
final user = userQuery.findFirst();
|
||||
userQuery.close();
|
||||
@@ -258,12 +348,21 @@ Future<Response> editMessageHandler(Request req) async {
|
||||
body: Body.fromString('content or components/componentsJson required'),
|
||||
);
|
||||
}
|
||||
final validationError = _validateComponents(components);
|
||||
if (validationError != null) {
|
||||
return Response.badRequest(
|
||||
body: Body.fromString(validationError),
|
||||
);
|
||||
}
|
||||
|
||||
message.content = _buildSearchContent(components, contentOverride);
|
||||
message.componentsJson = jsonEncode(components);
|
||||
final normalizedComponents = _normalizeComponentsForStorage(components);
|
||||
|
||||
message.content = _buildSearchContent(normalizedComponents, contentOverride);
|
||||
message.componentsJson = jsonEncode(normalizedComponents);
|
||||
message.editedAt = DateTime.now();
|
||||
messageBox.put(message);
|
||||
_attachReferencedFiles(components);
|
||||
_attachReferencedFiles(normalizedComponents);
|
||||
_syncMessageMediaIndex(message, normalizedComponents);
|
||||
|
||||
broadcastMessageUpdated(
|
||||
channelId: message.channelId,
|
||||
@@ -280,7 +379,6 @@ Future<Response> editMessageHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete a message
|
||||
Future<Response> deleteMessageHandler(Request req) async {
|
||||
final userId = userIdProperty[req];
|
||||
|
||||
@@ -311,7 +409,6 @@ Future<Response> deleteMessageHandler(Request req) async {
|
||||
);
|
||||
}
|
||||
|
||||
// Get user to verify ownership
|
||||
final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
|
||||
final user = userQuery.findFirst();
|
||||
userQuery.close();
|
||||
@@ -324,6 +421,7 @@ Future<Response> deleteMessageHandler(Request req) async {
|
||||
|
||||
final channelId = message.channelId;
|
||||
messageBox.remove(messageId);
|
||||
_removeMessageMediaIndex(messageId);
|
||||
|
||||
broadcastMessageDeleted(
|
||||
channelId: channelId,
|
||||
@@ -353,6 +451,27 @@ Map<String, dynamic> _messageToMap(ChatMessage message) {
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mediaToMap(ChatMessageMedia media) {
|
||||
final url = media.attachmentId != null && media.attachmentId!.isNotEmpty ? '/attachments/${media.attachmentId}' : media.externalUrl;
|
||||
return {
|
||||
'id': media.id,
|
||||
'messageId': media.messageId,
|
||||
'channelId': media.channelId,
|
||||
'authorUserId': media.authorUserId,
|
||||
'kind': media.kind,
|
||||
'url': url,
|
||||
'attachmentId': media.attachmentId,
|
||||
'externalUrl': media.externalUrl,
|
||||
'mimeType': media.mimeType,
|
||||
'originalFileName': media.originalFileName,
|
||||
'sizeBytes': media.sizeBytes,
|
||||
'width': media.width,
|
||||
'height': media.height,
|
||||
'thumbHash': media.thumbHash,
|
||||
'createdAt': media.createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>>? _parseComponents(Map<String, dynamic> body) {
|
||||
final rawComponents = body['components'];
|
||||
if (rawComponents is List) {
|
||||
@@ -400,7 +519,9 @@ List<Map<String, dynamic>>? _parseComponents(Map<String, dynamic> body) {
|
||||
|
||||
return <Map<String, dynamic>>[
|
||||
{
|
||||
'id': 'text_0',
|
||||
'type': 'text',
|
||||
'typeVersion': 1,
|
||||
'modifiers': <String>[],
|
||||
'fallback': content,
|
||||
'data': {
|
||||
@@ -410,6 +531,151 @@ List<Map<String, dynamic>>? _parseComponents(Map<String, dynamic> body) {
|
||||
];
|
||||
}
|
||||
|
||||
String? _validateComponents(List<Map<String, dynamic>> components) {
|
||||
final counter = _ComponentCounter();
|
||||
for (var i = 0; i < components.length; i++) {
|
||||
final err = _validateComponentNode(
|
||||
components[i],
|
||||
depth: 1,
|
||||
path: 'components[$i]',
|
||||
counter: counter,
|
||||
);
|
||||
if (err != null) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateComponentNode(
|
||||
Map<String, dynamic> node, {
|
||||
required int depth,
|
||||
required String path,
|
||||
required _ComponentCounter counter,
|
||||
}) {
|
||||
counter.count++;
|
||||
if (counter.count > _maxComponentNodes) {
|
||||
return 'Too many components (max $_maxComponentNodes)';
|
||||
}
|
||||
if (depth > _maxComponentDepth) {
|
||||
return 'Component nesting too deep at $path';
|
||||
}
|
||||
|
||||
final id = node['id'];
|
||||
if (id is! String || id.trim().isEmpty) {
|
||||
return 'Component id is required at $path';
|
||||
}
|
||||
final type = node['type'];
|
||||
if (type is! String || type.trim().isEmpty) {
|
||||
return 'Component type is required at $path';
|
||||
}
|
||||
final typeVersion = node['typeVersion'];
|
||||
if (typeVersion is! int || typeVersion <= 0) {
|
||||
return 'Component typeVersion must be a positive int at $path';
|
||||
}
|
||||
|
||||
final data = node['data'];
|
||||
if (data is! Map) {
|
||||
return 'Component data must be an object at $path';
|
||||
}
|
||||
|
||||
final children = data['children'];
|
||||
if (children is List) {
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
final child = children[i];
|
||||
if (child is! Map) {
|
||||
return 'children[$i] must be an object at $path';
|
||||
}
|
||||
final childMap = Map<String, dynamic>.from(
|
||||
child.map((k, v) => MapEntry(k.toString(), v)),
|
||||
);
|
||||
final err = _validateComponentNode(
|
||||
childMap,
|
||||
depth: depth + 1,
|
||||
path: '$path.children[$i]',
|
||||
counter: counter,
|
||||
);
|
||||
if (err != null) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final items = data['items'];
|
||||
if (items is List) {
|
||||
if (items.length > _maxMediaItemsPerComponent) {
|
||||
return 'Too many media items at $path (max $_maxMediaItemsPerComponent)';
|
||||
}
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
final item = items[i];
|
||||
if (item is! Map) {
|
||||
return 'media item must be an object at $path.items[$i]';
|
||||
}
|
||||
final itemMap = Map<String, dynamic>.from(
|
||||
item.map((k, v) => MapEntry(k.toString(), v)),
|
||||
);
|
||||
final err = _validateMediaRef(itemMap, '$path.items[$i]');
|
||||
if (err != null) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final thumbnail = data['thumbnail'];
|
||||
if (thumbnail is Map) {
|
||||
final err = _validateMediaRef(
|
||||
Map<String, dynamic>.from(
|
||||
thumbnail.map((k, v) => MapEntry(k.toString(), v)),
|
||||
),
|
||||
'$path.thumbnail',
|
||||
);
|
||||
if (err != null) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateMediaRef(Map<String, dynamic> media, String path) {
|
||||
final source = (media['source'] as String?)?.trim();
|
||||
final attachmentId = ((media['attachmentId'] ?? media['uploadId']) as String?)?.trim();
|
||||
final url = (media['url'] as String?)?.trim();
|
||||
|
||||
final resolvedSource = source ??
|
||||
(attachmentId != null && attachmentId.isNotEmpty ? 'local_upload' : (url != null && url.isNotEmpty ? 'external_url' : null));
|
||||
|
||||
if (resolvedSource == null) {
|
||||
return 'Media source is required at $path';
|
||||
}
|
||||
|
||||
if (resolvedSource == 'local_upload') {
|
||||
if (attachmentId == null || attachmentId.isEmpty) {
|
||||
return 'attachmentId is required for local_upload at $path';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (resolvedSource == 'external_url') {
|
||||
if (url == null || url.isEmpty) {
|
||||
return 'url is required for external_url at $path';
|
||||
}
|
||||
if (url.length > _maxExternalUrlLength) {
|
||||
return 'url is too long at $path';
|
||||
}
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || !uri.hasAuthority) {
|
||||
return 'url is invalid at $path';
|
||||
}
|
||||
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||
return 'url must use http/https at $path';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'Unsupported media source `$resolvedSource` at $path';
|
||||
}
|
||||
|
||||
String _buildSearchContent(
|
||||
List<Map<String, dynamic>> components,
|
||||
String? contentOverride,
|
||||
@@ -420,68 +686,461 @@ String _buildSearchContent(
|
||||
|
||||
final parts = <String>[];
|
||||
for (final component in components) {
|
||||
final fallback = component['fallback'];
|
||||
if (fallback is String && fallback.trim().isNotEmpty) {
|
||||
parts.add(fallback.trim());
|
||||
}
|
||||
|
||||
final data = component['data'];
|
||||
if (data is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
void addDataString(String key) {
|
||||
final value = data[key];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
parts.add(value.trim());
|
||||
}
|
||||
}
|
||||
|
||||
addDataString('text');
|
||||
addDataString('title');
|
||||
addDataString('description');
|
||||
addDataString('caption');
|
||||
_collectSearchText(component, parts);
|
||||
}
|
||||
|
||||
final content = parts.join(' ').trim();
|
||||
final content = parts.map((s) => s.trim()).where((s) => s.isNotEmpty).join(' ').replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
return content.isEmpty ? '[component]' : content;
|
||||
}
|
||||
|
||||
void _attachReferencedFiles(List<Map<String, dynamic>> components) {
|
||||
final referencedFileIds = <int>{};
|
||||
for (final component in components) {
|
||||
final data = component['data'];
|
||||
if (data is! Map) {
|
||||
continue;
|
||||
}
|
||||
void _collectSearchText(Map<String, dynamic> component, List<String> out) {
|
||||
final fallback = component['fallback'];
|
||||
if (fallback is String && fallback.trim().isNotEmpty) {
|
||||
out.add(fallback);
|
||||
}
|
||||
|
||||
final items = data['items'];
|
||||
if (items is! List) {
|
||||
continue;
|
||||
final data = component['data'];
|
||||
if (data is! Map) {
|
||||
return;
|
||||
}
|
||||
final map = Map<String, dynamic>.from(
|
||||
data.map((k, v) => MapEntry(k.toString(), v)),
|
||||
);
|
||||
const keys = <String>[
|
||||
'text',
|
||||
'title',
|
||||
'description',
|
||||
'caption',
|
||||
'siteName',
|
||||
'author',
|
||||
'originalFileName',
|
||||
];
|
||||
for (final key in keys) {
|
||||
final value = map[key];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
out.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
for (final item in items) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
final fileId = item['fileId'];
|
||||
final parsedId = switch (fileId) {
|
||||
int id when id > 0 => id,
|
||||
String value => int.tryParse(value),
|
||||
_ => null,
|
||||
};
|
||||
if (parsedId != null && parsedId > 0) {
|
||||
referencedFileIds.add(parsedId);
|
||||
final url = map['url'];
|
||||
if (url is String && url.trim().isNotEmpty) {
|
||||
out.add(url);
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri != null && uri.host.isNotEmpty) {
|
||||
out.add(uri.host);
|
||||
}
|
||||
}
|
||||
|
||||
final children = map['children'];
|
||||
if (children is List) {
|
||||
for (final child in children) {
|
||||
if (child is Map<String, dynamic>) {
|
||||
_collectSearchText(child, out);
|
||||
} else if (child is Map) {
|
||||
_collectSearchText(
|
||||
child.map((k, v) => MapEntry(k.toString(), v)),
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final fileId in referencedFileIds) {
|
||||
final file = fileBox.get(fileId);
|
||||
if (file == null || file.isAttached) {
|
||||
final items = map['items'];
|
||||
if (items is List) {
|
||||
for (final item in items) {
|
||||
if (item is Map) {
|
||||
final itemMap = Map<String, dynamic>.from(
|
||||
item.map((k, v) => MapEntry(k.toString(), v)),
|
||||
);
|
||||
for (final key in keys) {
|
||||
final value = itemMap[key];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
out.add(value);
|
||||
}
|
||||
}
|
||||
final itemUrl = itemMap['url'];
|
||||
if (itemUrl is String && itemUrl.trim().isNotEmpty) {
|
||||
out.add(itemUrl);
|
||||
final uri = Uri.tryParse(itemUrl);
|
||||
if (uri != null && uri.host.isNotEmpty) {
|
||||
out.add(uri.host);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _attachReferencedFiles(List<Map<String, dynamic>> components) {
|
||||
final referencedAttachmentIds = <String>{};
|
||||
_walkMediaRefs(components, (item) {
|
||||
final source = (item['source'] as String?)?.trim();
|
||||
if (source != null && source != 'local_upload') {
|
||||
return;
|
||||
}
|
||||
final attachmentId = item['attachmentId'] ?? item['uploadId'];
|
||||
if (attachmentId is String && attachmentId.trim().isNotEmpty) {
|
||||
referencedAttachmentIds.add(attachmentId.trim());
|
||||
}
|
||||
});
|
||||
|
||||
for (final attachmentId in referencedAttachmentIds) {
|
||||
final query = fileBox.query(ChatFile_.attachmentId.equals(attachmentId)).build();
|
||||
final file = query.findFirst();
|
||||
query.close();
|
||||
if (file == null || file.isAttached || file.status != 'ready') {
|
||||
continue;
|
||||
}
|
||||
file.isAttached = true;
|
||||
fileBox.put(file);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _normalizeComponentsForStorage(
|
||||
List<Map<String, dynamic>> components,
|
||||
) {
|
||||
final normalized = <Map<String, dynamic>>[];
|
||||
for (final rawComponent in components) {
|
||||
normalized.add(_normalizeComponentNode(rawComponent));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _normalizeComponentNode(Map<String, dynamic> rawNode) {
|
||||
final node = Map<String, dynamic>.from(rawNode);
|
||||
final dataRaw = node['data'];
|
||||
if (dataRaw is! Map) {
|
||||
return node;
|
||||
}
|
||||
|
||||
final data = Map<String, dynamic>.from(
|
||||
dataRaw.map((key, value) => MapEntry(key.toString(), value)),
|
||||
);
|
||||
|
||||
final itemsRaw = data['items'];
|
||||
if (itemsRaw is List) {
|
||||
final normalizedItems = <Map<String, dynamic>>[];
|
||||
for (final itemRaw in itemsRaw) {
|
||||
if (itemRaw is! Map) {
|
||||
continue;
|
||||
}
|
||||
final item = Map<String, dynamic>.from(
|
||||
itemRaw.map((k, v) => MapEntry(k.toString(), v)),
|
||||
);
|
||||
normalizedItems.add(_normalizeMediaItem(item));
|
||||
}
|
||||
data['items'] = normalizedItems;
|
||||
}
|
||||
|
||||
final thumbnailRaw = data['thumbnail'];
|
||||
if (thumbnailRaw is Map) {
|
||||
final thumb = Map<String, dynamic>.from(
|
||||
thumbnailRaw.map((k, v) => MapEntry(k.toString(), v)),
|
||||
);
|
||||
data['thumbnail'] = _normalizeMediaItem(thumb);
|
||||
}
|
||||
|
||||
final childrenRaw = data['children'];
|
||||
if (childrenRaw is List) {
|
||||
final normalizedChildren = <Map<String, dynamic>>[];
|
||||
for (final child in childrenRaw) {
|
||||
if (child is Map<String, dynamic>) {
|
||||
normalizedChildren.add(_normalizeComponentNode(child));
|
||||
} else if (child is Map) {
|
||||
normalizedChildren.add(
|
||||
_normalizeComponentNode(
|
||||
child.map((k, v) => MapEntry(k.toString(), v)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
data['children'] = normalizedChildren;
|
||||
}
|
||||
|
||||
node['data'] = data;
|
||||
return node;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _normalizeMediaItem(Map<String, dynamic> item) {
|
||||
final source = (item['source'] as String?)?.trim();
|
||||
final rawAttachmentId = item['attachmentId'] ?? item['uploadId'];
|
||||
final rawUrl = item['url'];
|
||||
|
||||
final attachmentId = rawAttachmentId is String ? rawAttachmentId.trim() : null;
|
||||
final externalUrl = rawUrl is String ? rawUrl.trim() : null;
|
||||
|
||||
final resolvedSource = source ??
|
||||
(attachmentId != null && attachmentId.isNotEmpty
|
||||
? 'local_upload'
|
||||
: (externalUrl != null && externalUrl.isNotEmpty ? 'external_url' : null));
|
||||
|
||||
if (resolvedSource == 'external_url') {
|
||||
item['source'] = 'external_url';
|
||||
item['status'] = 'ready';
|
||||
if (externalUrl != null && externalUrl.isNotEmpty) {
|
||||
item['url'] = externalUrl;
|
||||
}
|
||||
item.remove('attachmentId');
|
||||
item.remove('uploadId');
|
||||
return item;
|
||||
}
|
||||
|
||||
if (resolvedSource != 'local_upload' || attachmentId == null || attachmentId.isEmpty) {
|
||||
return item;
|
||||
}
|
||||
|
||||
final query = fileBox.query(ChatFile_.attachmentId.equals(attachmentId)).build();
|
||||
final file = query.findFirst();
|
||||
query.close();
|
||||
|
||||
item['source'] = 'local_upload';
|
||||
item['attachmentId'] = attachmentId;
|
||||
item['uploadId'] = attachmentId;
|
||||
|
||||
if (file == null) {
|
||||
item['status'] = 'failed';
|
||||
item['errorCode'] = 'upload_not_found';
|
||||
return item;
|
||||
}
|
||||
|
||||
item['originalFileName'] = file.originalFileName;
|
||||
item['sizeBytes'] = file.sizeBytes;
|
||||
if (file.width > 0) {
|
||||
item['width'] = file.width;
|
||||
}
|
||||
if (file.height > 0) {
|
||||
item['height'] = file.height;
|
||||
}
|
||||
item['mimeType'] = file.mimeType;
|
||||
|
||||
if (file.status == 'ready') {
|
||||
item['status'] = 'ready';
|
||||
item['url'] = '/attachments/${file.attachmentId}';
|
||||
item.remove('errorCode');
|
||||
} else if (file.status == 'failed') {
|
||||
item['status'] = 'failed';
|
||||
if (file.errorCode != null && file.errorCode!.isNotEmpty) {
|
||||
item['errorCode'] = file.errorCode;
|
||||
}
|
||||
} else {
|
||||
item['status'] = 'uploading';
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
void _syncMessageMediaIndex(
|
||||
ChatMessage message,
|
||||
List<Map<String, dynamic>> components,
|
||||
) {
|
||||
_removeMessageMediaIndex(message.id);
|
||||
|
||||
final entries = <ChatMessageMedia>[];
|
||||
_walkMediaRefs(components, (item) {
|
||||
final status = (item['status'] as String?)?.trim();
|
||||
if (status != null && status != 'ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
final source = (item['source'] as String?)?.trim();
|
||||
final mimeType = (item['mimeType'] as String?)?.trim();
|
||||
final originalFileName = (item['originalFileName'] as String?)?.trim();
|
||||
final thumbHash = (item['thumbHash'] as String?)?.trim();
|
||||
final sizeBytes = _asInt(item['sizeBytes']);
|
||||
final width = _asInt(item['width']);
|
||||
final height = _asInt(item['height']);
|
||||
|
||||
if (source == 'external_url') {
|
||||
final url = (item['url'] as String?)?.trim();
|
||||
if (url == null || url.isEmpty) {
|
||||
return;
|
||||
}
|
||||
entries.add(
|
||||
ChatMessageMedia(
|
||||
messageId: message.id,
|
||||
channelId: message.channelId,
|
||||
authorUserId: message.authorUserId,
|
||||
externalUrl: url,
|
||||
kind: _inferKind(mimeType: mimeType, url: url),
|
||||
mimeType: mimeType,
|
||||
originalFileName: originalFileName,
|
||||
sizeBytes: sizeBytes,
|
||||
width: width,
|
||||
height: height,
|
||||
thumbHash: thumbHash,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final attachmentId = ((item['attachmentId'] ?? item['uploadId']) as String?)?.trim();
|
||||
if (attachmentId == null || attachmentId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
entries.add(
|
||||
ChatMessageMedia(
|
||||
messageId: message.id,
|
||||
channelId: message.channelId,
|
||||
authorUserId: message.authorUserId,
|
||||
attachmentId: attachmentId,
|
||||
kind: _inferKind(
|
||||
mimeType: mimeType,
|
||||
url: '/attachments/$attachmentId',
|
||||
),
|
||||
mimeType: mimeType,
|
||||
originalFileName: originalFileName,
|
||||
sizeBytes: sizeBytes,
|
||||
width: width,
|
||||
height: height,
|
||||
thumbHash: thumbHash,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
for (final row in entries) {
|
||||
messageMediaBox.put(row);
|
||||
}
|
||||
}
|
||||
|
||||
void _removeMessageMediaIndex(int messageId) {
|
||||
final query = messageMediaBox.query(ChatMessageMedia_.messageId.equals(messageId)).build();
|
||||
query.remove();
|
||||
query.close();
|
||||
}
|
||||
|
||||
void _walkMediaRefs(
|
||||
List<Map<String, dynamic>> components,
|
||||
void Function(Map<String, dynamic> item) visitor,
|
||||
) {
|
||||
for (final component in components) {
|
||||
final dataRaw = component['data'];
|
||||
if (dataRaw is! Map) {
|
||||
continue;
|
||||
}
|
||||
final data = Map<String, dynamic>.from(
|
||||
dataRaw.map((k, v) => MapEntry(k.toString(), v)),
|
||||
);
|
||||
|
||||
final items = data['items'];
|
||||
if (items is List) {
|
||||
for (final item in items) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
visitor(
|
||||
Map<String, dynamic>.from(
|
||||
item.map((k, v) => MapEntry(k.toString(), v)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final thumbnail = data['thumbnail'];
|
||||
if (thumbnail is Map) {
|
||||
visitor(
|
||||
Map<String, dynamic>.from(
|
||||
thumbnail.map((k, v) => MapEntry(k.toString(), v)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final children = data['children'];
|
||||
if (children is List) {
|
||||
final childComponents = <Map<String, dynamic>>[];
|
||||
for (final child in children) {
|
||||
if (child is Map<String, dynamic>) {
|
||||
childComponents.add(child);
|
||||
} else if (child is Map) {
|
||||
childComponents.add(child.map((k, v) => MapEntry(k.toString(), v)));
|
||||
}
|
||||
}
|
||||
if (childComponents.isNotEmpty) {
|
||||
_walkMediaRefs(childComponents, visitor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _inferKind({String? mimeType, String? url}) {
|
||||
final lowerMime = (mimeType ?? '').toLowerCase();
|
||||
if (lowerMime.startsWith('image/')) {
|
||||
return 'image';
|
||||
}
|
||||
if (lowerMime.startsWith('video/')) {
|
||||
return 'video';
|
||||
}
|
||||
final lowerUrl = (url ?? '').toLowerCase();
|
||||
if (lowerUrl.endsWith('.png') ||
|
||||
lowerUrl.endsWith('.jpg') ||
|
||||
lowerUrl.endsWith('.jpeg') ||
|
||||
lowerUrl.endsWith('.gif') ||
|
||||
lowerUrl.endsWith('.webp') ||
|
||||
lowerUrl.endsWith('.avif')) {
|
||||
return 'image';
|
||||
}
|
||||
if (lowerUrl.endsWith('.mp4') || lowerUrl.endsWith('.webm') || lowerUrl.endsWith('.mov')) {
|
||||
return 'video';
|
||||
}
|
||||
return 'file';
|
||||
}
|
||||
|
||||
int? _asInt(Object? value) {
|
||||
return switch (value) {
|
||||
int v => v,
|
||||
double v => v.round(),
|
||||
String v => int.tryParse(v),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
class _ComponentCounter {
|
||||
int count = 0;
|
||||
}
|
||||
|
||||
Future<void> resolvePendingUploadsInMessages(String attachmentId) async {
|
||||
final messages = messageBox.getAll();
|
||||
for (final message in messages) {
|
||||
if (!message.componentsJson.contains(attachmentId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
dynamic decoded;
|
||||
try {
|
||||
decoded = jsonDecode(message.componentsJson);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
if (decoded is! List) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final components = <Map<String, dynamic>>[];
|
||||
for (final component in decoded) {
|
||||
if (component is Map<String, dynamic>) {
|
||||
components.add(component);
|
||||
} else if (component is Map) {
|
||||
components.add(component.map((k, v) => MapEntry(k.toString(), v)));
|
||||
}
|
||||
}
|
||||
if (components.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final normalized = _normalizeComponentsForStorage(components);
|
||||
final nextJson = jsonEncode(normalized);
|
||||
if (nextJson == message.componentsJson) {
|
||||
continue;
|
||||
}
|
||||
|
||||
message.componentsJson = nextJson;
|
||||
message.content = _buildSearchContent(normalized, null);
|
||||
messageBox.put(message);
|
||||
_attachReferencedFiles(normalized);
|
||||
_syncMessageMediaIndex(message, normalized);
|
||||
|
||||
broadcastMessageUpdated(
|
||||
channelId: message.channelId,
|
||||
message: _messageToMap(message),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user