Files
chat-server/lib/src/handlers/message_handlers.dart
T

1147 lines
31 KiB
Dart

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<Response> 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<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];
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<Response> 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<Response> 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<String, dynamic> _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<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) {
final components = <Map<String, dynamic>>[];
for (final component in rawComponents) {
if (component is Map<String, dynamic>) {
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 = <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)));
} else {
return null;
}
}
return components;
}
final content = (body['content'] as String?)?.trim();
if (content == null || content.isEmpty) {
return null;
}
return <Map<String, dynamic>>[
{
'id': 'text_0',
'type': 'text',
'typeVersion': 1,
'modifiers': <String>[],
'fallback': content,
'data': {
'text': content,
},
}
];
}
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,
) {
if (contentOverride != null && contentOverride.isNotEmpty) {
return contentOverride;
}
final parts = <String>[];
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<String, dynamic> component, List<String> 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<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);
}
}
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,
);
}
}
}
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),
);
}
}