import 'dart:convert'; import 'package:chat_server/models.dart'; import 'package:chat_server/objectbox.g.dart'; import 'package:relic/relic.dart'; import '../auth/auth_middleware.dart'; import '../gateway/gateway_handlers.dart'; import '../state/app_state.dart'; 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 getMessagesHandler(Request req) async { final channelIdStr = req.url.queryParameters['channelId']; final limitStr = req.url.queryParameters['limit']; final beforeStr = req.url.queryParameters['before']; final afterStr = req.url.queryParameters['after']; if (channelIdStr == null) { return Response.badRequest( body: Body.fromString('channelId required'), ); } if (beforeStr != null && afterStr != null) { return Response.badRequest( body: Body.fromString('Use either `before` or `after`, not both'), ); } final channelId = int.tryParse(channelIdStr); final parsedLimit = limitStr == null ? _defaultMessageLimit : int.tryParse(limitStr); int? before; int? after; if (channelId == null) { return Response.badRequest( body: Body.fromString('Invalid channelId'), ); } if (parsedLimit == null || parsedLimit <= 0) { return Response.badRequest( body: Body.fromString('Invalid limit'), ); } final limit = parsedLimit > _maxMessageLimit ? _maxMessageLimit : parsedLimit; if (beforeStr != null) { before = int.tryParse(beforeStr); if (before == null || before <= 0) { return Response.badRequest( body: Body.fromString('Invalid `before` cursor'), ); } } if (afterStr != null) { after = int.tryParse(afterStr); if (after == null || after <= 0) { return Response.badRequest( body: Body.fromString('Invalid `after` cursor'), ); } } final channel = channelBox.get(channelId); if (channel == null) { return Response.notFound( body: Body.fromString('Channel not found'), ); } var condition = ChatMessage_.channelId.equals(channelId); if (before != null) { condition = condition & ChatMessage_.id.lessThan(before); } else if (after != null) { condition = condition & ChatMessage_.id.greaterThan(after); } final queryBuilder = messageBox.query(condition); if (after != null) { queryBuilder.order(ChatMessage_.id); } else { queryBuilder.order(ChatMessage_.id, flags: Order.descending); } final query = queryBuilder.build(); query.limit = limit + 1; final fetchedMessages = query.find(); query.close(); final hasMore = fetchedMessages.length > limit; final messages = hasMore ? fetchedMessages.sublist(0, limit) : fetchedMessages; int? nextBefore; int? nextAfter; if (messages.isNotEmpty) { if (after != null) { nextBefore = messages.first.id; nextAfter = messages.last.id; } else { nextBefore = messages.last.id; nextAfter = messages.first.id; } } return Response.ok( body: { 'messages': messages.map(_messageToMap).toList(), 'channelId': channelId, 'limit': limit, 'hasMore': hasMore, 'nextBefore': nextBefore, 'nextAfter': nextAfter, }.toBody, ); } /// Get channel media index (for gallery grid view) Future 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 sendMessageHandler(Request req) async { final userId = userIdProperty[req]; if (userId == null) { return Response.unauthorized( body: Body.fromString('Not authenticated'), ); } final body = await req.asJson(); final channelId = body['channelId'] as int?; final components = _parseComponents(body); final contentOverride = (body['content'] as String?)?.trim(); if (components == null) { return Response.badRequest( body: Body.fromString('content or components/componentsJson required'), ); } 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'), ); } final channel = channelBox.get(channelId); if (channel == null) { return Response.notFound( body: Body.fromString('Channel not found'), ); } final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build(); final user = userQuery.findFirst(); userQuery.close(); if (user == null) { return Response.unauthorized( body: Body.fromString('User not found'), ); } final normalizedComponents = _normalizeComponentsForStorage(components); final message = ChatMessage( channelId: channelId, authorLocalId: user.id, authorUserId: user.userId, authorUsername: user.username, authorAvatarUrl: user.avatarUrl, authorHomeServer: user.homeServer, content: _buildSearchContent(normalizedComponents, contentOverride), componentsJson: jsonEncode(normalizedComponents), ); final id = messageBox.put(message); message.id = id; _attachReferencedFiles(normalizedComponents); _syncMessageMediaIndex(message, normalizedComponents); broadcastMessageCreated( channelId: channelId, message: _messageToMap(message), ); return Response.ok( body: { 'id': id, 'content': message.content, 'componentsJson': message.componentsJson, 'authorId': user.userId, 'authorUsername': user.username, 'authorAvatarUrl': user.avatarUrl, 'channelId': channelId, 'createdAt': message.createdAt.toIso8601String(), }.toBody, ); } Future editMessageHandler(Request req) async { final userId = userIdProperty[req]; if (userId == null) { return Response.unauthorized( body: Body.fromString('Not authenticated'), ); } final messageIdStr = req.rawPathParameters[#messageId]; if (messageIdStr == null) { return Response.badRequest( body: Body.fromString('Invalid message ID'), ); } final messageId = int.tryParse(messageIdStr); if (messageId == null) { return Response.badRequest( body: Body.fromString('Invalid message ID'), ); } final message = messageBox.get(messageId); if (message == null) { return Response.notFound( body: Body.fromString('Message not found'), ); } final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build(); final user = userQuery.findFirst(); userQuery.close(); if (user == null || message.authorLocalId != user.id) { return Response.forbidden( body: Body.fromString('You can only edit your own messages'), ); } final body = await req.asJson(); final components = _parseComponents(body); final contentOverride = (body['content'] as String?)?.trim(); if (components == null) { return Response.badRequest( body: Body.fromString('content or components/componentsJson required'), ); } final validationError = _validateComponents(components); if (validationError != null) { return Response.badRequest( body: Body.fromString(validationError), ); } final normalizedComponents = _normalizeComponentsForStorage(components); message.content = _buildSearchContent(normalizedComponents, contentOverride); message.componentsJson = jsonEncode(normalizedComponents); message.editedAt = DateTime.now(); messageBox.put(message); _attachReferencedFiles(normalizedComponents); _syncMessageMediaIndex(message, normalizedComponents); broadcastMessageUpdated( channelId: message.channelId, message: _messageToMap(message), ); return Response.ok( body: { 'id': message.id, 'content': message.content, 'componentsJson': message.componentsJson, 'editedAt': message.editedAt?.toIso8601String(), }.toBody, ); } Future deleteMessageHandler(Request req) async { final userId = userIdProperty[req]; if (userId == null) { return Response.unauthorized( body: Body.fromString('Not authenticated'), ); } final messageIdStr = req.rawPathParameters[#messageId]; if (messageIdStr == null) { return Response.badRequest( body: Body.fromString('Invalid message ID'), ); } final messageId = int.tryParse(messageIdStr); if (messageId == null) { return Response.badRequest( body: Body.fromString('Invalid message ID'), ); } final message = messageBox.get(messageId); if (message == null) { return Response.notFound( body: Body.fromString('Message not found'), ); } final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build(); final user = userQuery.findFirst(); userQuery.close(); if (user == null || message.authorLocalId != user.id) { return Response.forbidden( body: Body.fromString('You can only delete your own messages'), ); } final channelId = message.channelId; messageBox.remove(messageId); _removeMessageMediaIndex(messageId); broadcastMessageDeleted( channelId: channelId, messageId: messageId, ); return Response.ok( body: { 'deleted': true, 'id': messageId, }.toBody, ); } Map _messageToMap(ChatMessage message) { return { 'id': message.id, 'content': message.content, 'componentsJson': message.componentsJson, 'authorId': message.authorUserId, 'authorUsername': message.authorUsername, 'authorAvatarUrl': message.authorAvatarUrl, 'authorHomeServer': message.authorHomeServer, 'channelId': message.channelId, 'createdAt': message.createdAt.toIso8601String(), 'editedAt': message.editedAt?.toIso8601String(), }; } Map _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>? _parseComponents(Map body) { final rawComponents = body['components']; if (rawComponents is List) { final components = >[]; for (final component in rawComponents) { if (component is Map) { components.add(component); } else if (component is Map) { components.add(component.map((k, v) => MapEntry(k.toString(), v))); } else { return null; } } return components; } final rawComponentsJson = body['componentsJson']; if (rawComponentsJson is String && rawComponentsJson.trim().isNotEmpty) { dynamic decoded; try { decoded = jsonDecode(rawComponentsJson); } catch (_) { return null; } if (decoded is! List) { return null; } final components = >[]; for (final component in decoded) { if (component is Map) { components.add(component); } else if (component is Map) { components.add(component.map((k, v) => MapEntry(k.toString(), v))); } else { return null; } } return components; } final content = (body['content'] as String?)?.trim(); if (content == null || content.isEmpty) { return null; } return >[ { 'id': 'text_0', 'type': 'text', 'typeVersion': 1, 'modifiers': [], 'fallback': content, 'data': { 'text': content, }, } ]; } String? _validateComponents(List> 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 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.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.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.from( thumbnail.map((k, v) => MapEntry(k.toString(), v)), ), '$path.thumbnail', ); if (err != null) { return err; } } return null; } String? _validateMediaRef(Map 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> components, String? contentOverride, ) { if (contentOverride != null && contentOverride.isNotEmpty) { return contentOverride; } final parts = []; for (final component in components) { _collectSearchText(component, parts); } final content = parts.map((s) => s.trim()).where((s) => s.isNotEmpty).join(' ').replaceAll(RegExp(r'\s+'), ' ').trim(); return content.isEmpty ? '[component]' : content; } void _collectSearchText(Map component, List out) { final fallback = component['fallback']; if (fallback is String && fallback.trim().isNotEmpty) { out.add(fallback); } final data = component['data']; if (data is! Map) { return; } final map = Map.from( data.map((k, v) => MapEntry(k.toString(), v)), ); const keys = [ '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); } } 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) { _collectSearchText(child, out); } else if (child is Map) { _collectSearchText( child.map((k, v) => MapEntry(k.toString(), v)), out, ); } } } final items = map['items']; if (items is List) { for (final item in items) { if (item is Map) { final itemMap = Map.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> components) { final referencedAttachmentIds = {}; _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> _normalizeComponentsForStorage( List> components, ) { final normalized = >[]; for (final rawComponent in components) { normalized.add(_normalizeComponentNode(rawComponent)); } return normalized; } Map _normalizeComponentNode(Map rawNode) { final node = Map.from(rawNode); final dataRaw = node['data']; if (dataRaw is! Map) { return node; } final data = Map.from( dataRaw.map((key, value) => MapEntry(key.toString(), value)), ); final itemsRaw = data['items']; if (itemsRaw is List) { final normalizedItems = >[]; for (final itemRaw in itemsRaw) { if (itemRaw is! Map) { continue; } final item = Map.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.from( thumbnailRaw.map((k, v) => MapEntry(k.toString(), v)), ); data['thumbnail'] = _normalizeMediaItem(thumb); } final childrenRaw = data['children']; if (childrenRaw is List) { final normalizedChildren = >[]; for (final child in childrenRaw) { if (child is Map) { 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 _normalizeMediaItem(Map 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> components, ) { _removeMessageMediaIndex(message.id); final entries = []; _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> components, void Function(Map item) visitor, ) { for (final component in components) { final dataRaw = component['data']; if (dataRaw is! Map) { continue; } final data = Map.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.from( item.map((k, v) => MapEntry(k.toString(), v)), ), ); } } final thumbnail = data['thumbnail']; if (thumbnail is Map) { visitor( Map.from( thumbnail.map((k, v) => MapEntry(k.toString(), v)), ), ); } final children = data['children']; if (children is List) { final childComponents = >[]; for (final child in children) { if (child is Map) { 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 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 = >[]; for (final component in decoded) { if (component is Map) { 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), ); } }