import 'package:dio/dio.dart'; import 'package:island/database/drift_db.dart'; import 'package:island/database/message.dart'; import 'package:island/models/chat.dart'; import 'package:island/models/file.dart'; import 'package:island/services/file.dart'; import 'package:island/widgets/alert.dart'; import 'package:uuid/uuid.dart'; class MessageRepository { final SnChatRoom room; final SnChatMember identity; final Dio _apiClient; final AppDatabase _database; final Map pendingMessages = {}; final Map> fileUploadProgress = {}; MessageRepository(this.room, this.identity, this._apiClient, this._database); Future getLastMessages() async { final dbMessages = await _database.getMessagesForRoom( room.id, offset: 0, limit: 1, ); if (dbMessages.isEmpty) { return null; } return _database.companionToMessage(dbMessages.first); } Future syncMessages() async { final lastMessage = await getLastMessages(); if (lastMessage == null) return false; try { final resp = await _apiClient.post( '/chat/${room.id}/sync', data: { 'last_sync_timestamp': lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch, }, ); final response = MessageSyncResponse.fromJson(resp.data); for (final change in response.changes) { switch (change.action) { case MessageChangeAction.create: await receiveMessage(change.message!); break; case MessageChangeAction.update: await receiveMessageUpdate(change.message!); break; case MessageChangeAction.delete: await receiveMessageDeletion(change.messageId.toString()); break; } } } catch (err) { showErrorAlert(err); } return true; } Future> listMessages({ int offset = 0, int take = 20, bool synced = false, }) async { try { final localMessages = await _getCachedMessages( room.id, offset: offset, take: take, ); // If it already synced with the remote, skip this if (offset == 0 && !synced) { // Fetch latest messages _fetchAndCacheMessages(room.id, offset: offset, take: take); if (localMessages.isNotEmpty) { return localMessages; } } return await _fetchAndCacheMessages(room.id, offset: offset, take: take); } catch (e) { // If API fails but we have local messages, return them final localMessages = await _getCachedMessages( room.id, offset: offset, take: take, ); if (localMessages.isNotEmpty) { return localMessages; } rethrow; } } Future> _getCachedMessages( String roomId, { int offset = 0, int take = 20, }) async { // Get messages from local database final dbMessages = await _database.getMessagesForRoom( roomId, offset: offset, limit: take, ); final dbLocalMessages = dbMessages.map(_database.companionToMessage).toList(); // Combine with pending messages final pendingForRoom = pendingMessages.values.where((msg) => msg.roomId == roomId).toList(); // Sort by timestamp descending (newest first) final allMessages = [...pendingForRoom, ...dbLocalMessages]; allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); // Apply pagination if (offset >= allMessages.length) { return []; } final end = (offset + take) > allMessages.length ? allMessages.length : (offset + take); return allMessages.sublist(offset, end); } Future> _fetchAndCacheMessages( String roomId, { int offset = 0, int take = 20, }) async { final response = await _apiClient.get( '/chat/$roomId/messages', queryParameters: {'offset': offset, 'take': take}, ); final total = int.parse(response.headers.value('X-Total') ?? '0'); final List data = response.data; final messages = data.map((json) { final remoteMessage = SnChatMessage.fromJson(json); return LocalChatMessage.fromRemoteMessage( remoteMessage, MessageStatus.sent, ); }).toList(); for (final message in messages) { await _database.saveMessage(_database.messageToCompanion(message)); if (message.nonce != null) { pendingMessages.removeWhere( (_, pendingMsg) => pendingMsg.nonce == message.nonce, ); } } return messages; } Future sendMessage( String atk, String baseUrl, String roomId, String content, String nonce, { required List attachments, Map? meta, SnChatMessage? replyingTo, SnChatMessage? forwardingTo, SnChatMessage? editingTo, Function(LocalChatMessage)? onPending, Function(String, Map)? onProgress, }) async { // Generate a unique nonce for this message final nonce = const Uuid().v4(); // Create a local message with pending status final mockMessage = SnChatMessage( id: 'pending_$nonce', chatRoomId: roomId, senderId: identity.id, content: content, createdAt: DateTime.now(), updatedAt: DateTime.now(), nonce: nonce, sender: identity, ); final localMessage = LocalChatMessage.fromRemoteMessage( mockMessage, MessageStatus.pending, ); // Store in memory and database pendingMessages[localMessage.id] = localMessage; fileUploadProgress[localMessage.id] = {}; await _database.saveMessage(_database.messageToCompanion(localMessage)); onPending?.call(localMessage); try { var cloudAttachments = List.empty(growable: true); // Upload files for (var idx = 0; idx < attachments.length; idx++) { final cloudFile = await putMediaToCloud( fileData: attachments[idx].data, atk: atk, baseUrl: baseUrl, filename: attachments[idx].data.name ?? 'Post media', mimetype: attachments[idx].data.mimeType ?? switch (attachments[idx].type) { UniversalFileType.image => 'image/unknown', UniversalFileType.video => 'video/unknown', UniversalFileType.audio => 'audio/unknown', UniversalFileType.file => 'application/octet-stream', }, onProgress: (progress, _) { fileUploadProgress[localMessage.id]?[idx] = progress; onProgress?.call( localMessage.id, fileUploadProgress[localMessage.id] ?? {}, ); }, ).future; if (cloudFile == null) { throw ArgumentError('Failed to upload the file...'); } cloudAttachments.add(cloudFile); } // Send to server final response = await _apiClient.request( editingTo == null ? '/chat/$roomId/messages' : '/chat/$roomId/messages/${editingTo.id}', data: { 'content': content, 'attachments_id': cloudAttachments.map((e) => e.id).toList(), 'replied_message_id': replyingTo?.id, 'forwarded_message_id': forwardingTo?.id, 'meta': meta, 'nonce': nonce, }, options: Options(method: editingTo == null ? 'POST' : 'PATCH'), ); // Update with server response final remoteMessage = SnChatMessage.fromJson(response.data); final updatedMessage = LocalChatMessage.fromRemoteMessage( remoteMessage, MessageStatus.sent, ); // Remove from pending and update in database pendingMessages.remove(localMessage.id); await _database.deleteMessage(localMessage.id); await _database.saveMessage(_database.messageToCompanion(updatedMessage)); return updatedMessage; } catch (e) { // Update status to failed localMessage.status = MessageStatus.failed; pendingMessages[localMessage.id] = localMessage; await _database.updateMessageStatus( localMessage.id, MessageStatus.failed, ); rethrow; } } Future retryMessage(String pendingMessageId) async { final message = pendingMessages[pendingMessageId]; if (message == null) { throw Exception('Message not found'); } // Update status back to pending message.status = MessageStatus.pending; pendingMessages[pendingMessageId] = message; await _database.updateMessageStatus( pendingMessageId, MessageStatus.pending, ); try { // Send to server var remoteMessage = message.toRemoteMessage(); final response = await _apiClient.post( '/chat/${message.roomId}/messages', data: { 'content': remoteMessage.content, 'attachments_id': remoteMessage.attachments, 'meta': remoteMessage.meta, 'nonce': message.nonce, }, ); // Update with server response remoteMessage = SnChatMessage.fromJson(response.data); final updatedMessage = LocalChatMessage.fromRemoteMessage( remoteMessage, MessageStatus.sent, ); // Remove from pending and update in database pendingMessages.remove(pendingMessageId); await _database.deleteMessage(pendingMessageId); await _database.saveMessage(_database.messageToCompanion(updatedMessage)); return updatedMessage; } catch (e) { // Update status to failed message.status = MessageStatus.failed; pendingMessages[pendingMessageId] = message; await _database.updateMessageStatus( pendingMessageId, MessageStatus.failed, ); rethrow; } } Future receiveMessage(SnChatMessage remoteMessage) async { final localMessage = LocalChatMessage.fromRemoteMessage( remoteMessage, MessageStatus.sent, ); if (remoteMessage.nonce != null) { pendingMessages.removeWhere( (_, pendingMsg) => pendingMsg.nonce == remoteMessage.nonce, ); } await _database.saveMessage(_database.messageToCompanion(localMessage)); return localMessage; } Future receiveMessageUpdate( SnChatMessage remoteMessage, ) async { final localMessage = LocalChatMessage.fromRemoteMessage( remoteMessage, MessageStatus.sent, ); await _database.updateMessage(_database.messageToCompanion(localMessage)); return localMessage; } Future receiveMessageDeletion(String messageId) async { // Remove from pending messages if exists pendingMessages.remove(messageId); // Delete from local database await _database.deleteMessage(messageId); } Future updateMessage( String messageId, String content, { List? attachments, Map? meta, }) async { final message = pendingMessages[messageId]; if (message != null) { // Update pending message final rmMessage = message.toRemoteMessage(); final updatedRemoteMessage = rmMessage.copyWith( content: content, meta: meta ?? rmMessage.meta, ); final updatedLocalMessage = LocalChatMessage.fromRemoteMessage( updatedRemoteMessage, MessageStatus.pending, ); pendingMessages[messageId] = updatedLocalMessage; await _database.updateMessage( _database.messageToCompanion(updatedLocalMessage), ); return message; } try { // Update on server final response = await _apiClient.put( '/chat/${room.id}/messages/$messageId', data: {'content': content, 'attachments': attachments, 'meta': meta}, ); // Update local copy final remoteMessage = SnChatMessage.fromJson(response.data); final updatedMessage = LocalChatMessage.fromRemoteMessage( remoteMessage, MessageStatus.sent, ); await _database.updateMessage( _database.messageToCompanion(updatedMessage), ); return updatedMessage; } catch (e) { rethrow; } } Future deleteMessage(String messageId) async { try { await _apiClient.delete('/chat/${room.id}/messages/$messageId'); pendingMessages.remove(messageId); await _database.deleteMessage(messageId); } catch (e) { rethrow; } } Future getMessageById(String messageId) async { try { // Attempt to get the message from the local database final localMessage = await (_database.select(_database.chatMessages) ..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull(); if (localMessage != null) { return _database.companionToMessage(localMessage); } // If not found locally, fetch from the server final response = await _apiClient.get( '/chat/${room.id}/messages/$messageId', ); final remoteMessage = SnChatMessage.fromJson(response.data); final message = LocalChatMessage.fromRemoteMessage( remoteMessage, MessageStatus.sent, ); // Save the fetched message to the local database await _database.saveMessage(_database.messageToCompanion(message)); return message; } catch (e) { if (e is DioException) return null; // Handle errors rethrow; } } Future markMessageAsRead(String messageId) async { try { await _database.markMessageAsRead(messageId); } catch (e) { showErrorAlert(e); } } }