import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'dart:async'; import 'dart:io'; import 'package:tugas_akhir_supabase/screens/community/models/message.dart'; import 'package:uuid/uuid.dart'; class MessageLoadResult { final List messages; final bool hasMore; MessageLoadResult({required this.messages, required this.hasMore}); } class MessageSendResult { final bool success; final String? errorMessage; final Message? message; MessageSendResult({required this.success, this.errorMessage, this.message}); } class MessageService { final _supabase = Supabase.instance.client; final Map> _messageReadReceipts = {}; // Message-specific timers Timer? _refreshTimer; Timer? _readStatusTimer; Timer? _cleanupTimer; RealtimeChannel? _subscription; final int _pageSize = 20; // Cache final Map _emailCache = {}; final Map _usernameCache = {}; final Map _profilePictureCache = {}; // State List _cachedMessages = []; DateTime? _lastFetch; String? _currentUserId; StreamSubscription? _messagesSubscription; // Constants static const int _fetchLimit = 30; static const Duration _refreshInterval = Duration(seconds: 30); static const Duration _readUpdateInterval = Duration(seconds: 15); // Getters String? get currentUserId { _currentUserId ??= _supabase.auth.currentUser?.id; return _currentUserId; } // Initialize void setupRefreshTimer({required Function() onRefresh}) { _refreshTimer?.cancel(); _refreshTimer = Timer.periodic(const Duration(minutes: 2), (_) { onRefresh(); }); } void setupReadStatusTimer({required Function() onUpdate}) { _readStatusTimer?.cancel(); _readStatusTimer = Timer.periodic(const Duration(seconds: 30), (_) { onUpdate(); }); } void setupCleanupTimer({required Function() onCleanup}) { _cleanupTimer?.cancel(); _cleanupTimer = Timer.periodic(const Duration(hours: 24), (_) { onCleanup(); }); } // Cleanup void dispose() { _subscription?.unsubscribe(); _refreshTimer?.cancel(); _readStatusTimer?.cancel(); _cleanupTimer?.cancel(); } // Real-time subscription void setupMessagesSubscription( Function(Message) onNewMessage, Function(String, String) onReadStatusUpdate, ) { try { // Unsubscribe if already subscribed _subscription?.unsubscribe(); print('[DEBUG] Setting up real-time subscription'); // Subscribe to read receipts if available _subscribeToReadReceipts(onReadStatusUpdate); // Subscribe to messages _subscription = _supabase .channel('public:community_messages') .onPostgresChanges( event: PostgresChangeEvent.insert, schema: 'public', table: 'community_messages', callback: (payload) { print('[DEBUG] Received real-time update: ${payload.eventType}'); try { final data = payload.newRecord; // Skip messages older than 30 days final messageDate = DateTime.parse( data['created_at'] ?? DateTime.now().toIso8601String(), ); if (messageDate.isBefore( DateTime.now().subtract(Duration(days: 30)), )) { return; } final senderId = data['sender_id'] as String? ?? ''; if (senderId.isEmpty) return; // Get cached user info if available String senderEmail = _emailCache[senderId] ?? ''; String senderUsername = _usernameCache[senderId] ?? ''; String? avatarUrl = _profilePictureCache[senderId]; // Use email from message if available if (senderEmail.isEmpty && data['sender_email'] != null) { senderEmail = data['sender_email'] as String; _emailCache[senderId] = senderEmail; } // Derive username if needed if (senderUsername.isEmpty) { if (senderEmail.isNotEmpty) { senderUsername = senderEmail.split('@')[0]; } else { senderUsername = 'user-${senderId.substring(0, senderId.length.clamp(0, 6))}'; } } // Parse reply data String? replyToId = data['reply_to_id'] as String?; String? replyToContent = data['reply_to_content'] as String?; String? replyToSenderEmail = data['reply_to_sender_email'] as String?; String? replyToSenderUsername = data['reply_to_sender_username'] as String?; // If replyToSenderUsername is not available but email is, derive username from email if (replyToSenderUsername == null && replyToSenderEmail != null && replyToSenderEmail.isNotEmpty) { replyToSenderUsername = replyToSenderEmail.split('@')[0]; } final message = Message( id: data['id'] as String? ?? 'msg-${DateTime.now().millisecondsSinceEpoch}', content: data['content'] as String? ?? '', senderEmail: senderEmail, senderUsername: senderUsername, senderUserId: senderId, imageUrl: data['image_url'] as String?, createdAt: messageDate, replyToId: replyToId, replyToContent: replyToContent, replyToSenderEmail: replyToSenderEmail, replyToSenderUsername: replyToSenderUsername, avatarUrl: avatarUrl, ); // Notify callback onNewMessage(message); // Fetch profile in background if needed if (avatarUrl == null) { _fetchUserProfile(senderId) .then((profile) { if (profile != null && profile['avatar_url'] != null) { _profilePictureCache[senderId] = profile['avatar_url']; // Update username if available if (profile['username'] != null) { _usernameCache[senderId] = profile['username']; } } }) .catchError((e) { print( '[ERROR] Error fetching profile in background: $e', ); }); } } catch (e) { print('[ERROR] Error processing real-time message: $e'); } }, ) .subscribe((status, error) { if (error != null) { print('[ERROR] Subscription error: $error'); // Try to reconnect after a delay Future.delayed(const Duration(seconds: 5), () { setupMessagesSubscription(onNewMessage, onReadStatusUpdate); }); } else { print('[DEBUG] Subscription status: $status'); } }); } catch (e) { print('[ERROR] Failed to setup subscription: $e'); // Try to reconnect after a delay Future.delayed(const Duration(seconds: 5), () { setupMessagesSubscription(onNewMessage, onReadStatusUpdate); }); } } // Subscribe to read receipts void _subscribeToReadReceipts(Function(String, String) onReadStatusUpdate) { try { _supabase .channel('public:message_read_receipts') .onPostgresChanges( event: PostgresChangeEvent.insert, schema: 'public', table: 'message_read_receipts', callback: (payload) { try { final data = payload.newRecord; final messageId = data['message_id'] as String?; final userId = data['user_id'] as String?; if (messageId != null && userId != null) { // Update local read status final readers = _messageReadReceipts[messageId] ?? {}; readers.add(userId); _messageReadReceipts[messageId] = readers; // Notify callback onReadStatusUpdate(messageId, userId); } } catch (e) { print('[ERROR] Error processing read receipt: $e'); } }, ) .subscribe(); } catch (e) { print('[INFO] Could not subscribe to read receipts: $e'); } } // Delete message Future deleteMessage(Message message) async { try { final currentUserId = _supabase.auth.currentUser?.id; if (currentUserId == null || message.senderUserId != currentUserId) { throw Exception('Not authorized to delete this message'); } await _supabase .from('community_messages') .delete() .eq('id', message.id) .eq('sender_id', currentUserId); return true; } catch (e) { print('[ERROR] Failed to delete message: $e'); rethrow; } } // Load messages Future loadMessages({ bool forceRefresh = false, bool loadNew = false, List? existingMessages, String? groupId, }) async { try { // Check if we should use cache if (!forceRefresh && !loadNew && _cachedMessages.isNotEmpty) { return MessageLoadResult(messages: _cachedMessages, hasMore: true); } // For loading new messages, use the most recent as reference DateTime? since; if (loadNew && existingMessages != null && existingMessages.isNotEmpty) { // Sort to find the most recent final sortedMessages = List.from(existingMessages) ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); since = sortedMessages.first.createdAt; } // Build the query - use dynamic to avoid type conflicts final queryBase = _supabase.from('community_messages').select(); // Apply filters and ordering dynamically final query = loadNew && since != null ? queryBase .gte('created_at', since.toIso8601String()) .order('created_at', ascending: false) : queryBase .limit(_fetchLimit) .order('created_at', ascending: false); // Execute query with timeout final data = await query.timeout( const Duration(seconds: 5), onTimeout: () { throw TimeoutException('Database query timed out'); }, ); // Process results List messages = []; if (data.isNotEmpty) { for (final item in data) { try { // Extract user info for Message.fromMap final senderId = item['sender_id'] as String? ?? ''; final senderEmail = item['sender_email'] as String? ?? _emailCache[senderId] ?? ''; final senderUsername = _usernameCache[senderId] ?? senderEmail.split('@')[0]; final avatarUrl = _profilePictureCache[senderId]; final message = Message.fromMap( item, senderEmail: senderEmail, senderUsername: senderUsername, avatarUrl: avatarUrl, ); // Skip messages with conversion issues if (message.id.isEmpty) continue; messages.add(message); } catch (e) { print('[ERROR] Failed to parse message: $e'); } } } _lastFetch = DateTime.now(); if (!loadNew) { // Replace cache for normal loads _cachedMessages = messages; } else if (loadNew && messages.isNotEmpty) { // Prepend new messages to cache final newIds = messages.map((m) => m.id).toSet(); _cachedMessages = [ ...messages, ..._cachedMessages.where((m) => !newIds.contains(m.id)), ]; } // Return results return MessageLoadResult( messages: messages, hasMore: messages.length >= _fetchLimit, ); } catch (e) { print('[ERROR] Failed to load messages: $e'); // Return empty result on error but don't throw return MessageLoadResult(messages: [], hasMore: false); } } // Load more messages (pagination) Future loadMoreMessages( List existingMessages, ) async { if (existingMessages.isEmpty) { return await loadMessages(); } try { // Get oldest message timestamp for pagination final oldestMessage = existingMessages.reduce( (a, b) => a.createdAt.isBefore(b.createdAt) ? a : b, ); // Query older messages final data = await _supabase .from('community_messages') .select() .lte('created_at', oldestMessage.createdAt.toIso8601String()) .order('created_at', ascending: false) .limit(_fetchLimit) .timeout( const Duration(seconds: 5), onTimeout: () { throw TimeoutException('Database query timed out'); }, ); // Process results List messages = []; if (data.isNotEmpty) { for (final item in data) { try { // Extract user info for Message.fromMap final senderId = item['sender_id'] as String? ?? ''; final senderEmail = item['sender_email'] as String? ?? _emailCache[senderId] ?? ''; final senderUsername = _usernameCache[senderId] ?? senderEmail.split('@')[0]; final avatarUrl = _profilePictureCache[senderId]; final message = Message.fromMap( item, senderEmail: senderEmail, senderUsername: senderUsername, avatarUrl: avatarUrl, ); messages.add(message); } catch (e) { print('[ERROR] Failed to parse message: $e'); } } } // Update cache without duplicates final newIds = messages.map((m) => m.id).toSet(); _cachedMessages = [ ..._cachedMessages, ...messages.where( (m) => !_cachedMessages.map((cm) => cm.id).contains(m.id), ), ]; // Return results return MessageLoadResult( messages: messages, hasMore: messages.length >= _fetchLimit, ); } catch (e) { print('[ERROR] Failed to load more messages: $e'); return MessageLoadResult(messages: [], hasMore: false); } } // Search messages Future> searchMessages(String query) async { if (query.isEmpty) return []; try { // Use ilike for case-insensitive search final response = await _supabase .from('community_messages') .select() .ilike('content', '%$query%') .order('created_at', ascending: false) .limit(50); List results = []; for (final item in response) { try { // Extract user info for Message.fromMap final senderId = item['sender_id'] as String? ?? ''; final senderEmail = item['sender_email'] as String? ?? _emailCache[senderId] ?? ''; final senderUsername = _usernameCache[senderId] ?? senderEmail.split('@')[0]; final avatarUrl = _profilePictureCache[senderId]; results.add( Message.fromMap( item, senderEmail: senderEmail, senderUsername: senderUsername, avatarUrl: avatarUrl, ), ); } catch (e) { print('[ERROR] Failed to parse search result: $e'); } } return results; } catch (e) { print('[ERROR] Failed to search messages: $e'); return []; } } // Send message Future sendMessage({ String? text, File? imageFile, Message? replyToMessage, String? currentUsername, String? currentEmail, required Function(Message) onOptimisticUpdate, }) async { final messageText = text?.trim() ?? ''; // Allow empty text when sending an image, but require at least one (text or image) if (messageText.isEmpty && imageFile == null) { return MessageSendResult( success: false, errorMessage: 'No content to send', ); } try { // Get current user ID final userId = _supabase.auth.currentUser?.id; if (userId == null) { throw Exception('User not logged in'); } final userEmail = currentEmail ?? _supabase.auth.currentUser?.email ?? ''; // Generate ID final timestamp = DateTime.now().millisecondsSinceEpoch; final messageId = 'msg-$timestamp-${userId.substring(0, userId.length.clamp(0, 6))}'; print('[DEBUG] Sending message: $messageId'); print( '[DEBUG] Message text: "$messageText", has image: ${imageFile != null}', ); // Upload image if available String? imageUrl; if (imageFile != null) { try { print('[DEBUG] Uploading image for message: $messageId'); imageUrl = await _uploadImage(imageFile); if (imageUrl == null) { throw Exception('Failed to upload image - URL is null'); } print('[DEBUG] Image uploaded: $imageUrl'); } catch (e) { print('[ERROR] Image upload failed: $e'); return MessageSendResult( success: false, errorMessage: 'Gagal mengunggah gambar: ${e.toString()}', ); } } // Create optimistic message final optimisticMessage = Message( id: messageId, content: messageText, senderEmail: userEmail, senderUsername: currentUsername ?? userEmail.split('@')[0], senderUserId: userId, imageUrl: imageUrl, createdAt: DateTime.now(), replyToId: replyToMessage?.id, replyToContent: replyToMessage?.content, replyToSenderEmail: replyToMessage?.senderEmail, replyToSenderUsername: replyToMessage?.senderUsername, avatarUrl: _profilePictureCache[userId], ); // Trigger optimistic update onOptimisticUpdate(optimisticMessage); // Prepare message data final messageData = optimisticMessage.toMap(); // Ensure content is included even if it's empty (to prevent null values) if (!messageData.containsKey('content')) { messageData['content'] = messageText; } // Insert to database print('[DEBUG] Saving message to database: ${messageData.toString()}'); bool saveSuccess = false; try { // First try with all data including reply fields await _supabase.from('community_messages').insert(messageData); print('[DEBUG] Message saved successfully'); saveSuccess = true; } catch (e) { print('[ERROR] Failed to save message: $e'); // If the message has reply data, try without it if (replyToMessage != null) { print('[DEBUG] Retrying without reply data'); // Remove reply fields final retryData = Map.from(messageData); retryData.remove('reply_to_id'); retryData.remove('reply_to_content'); retryData.remove('reply_to_sender_email'); retryData.remove('reply_to_sender_username'); try { await _supabase.from('community_messages').insert(retryData); print('[DEBUG] Message saved without reply data'); saveSuccess = true; } catch (retryError) { print('[ERROR] Retry also failed: $retryError'); return MessageSendResult( success: false, errorMessage: 'Gagal menyimpan pesan: ${retryError.toString()}', ); } } else { return MessageSendResult( success: false, errorMessage: 'Gagal menyimpan pesan: ${e.toString()}', ); } } return MessageSendResult( success: saveSuccess, message: optimisticMessage, ); } catch (e) { print('[ERROR] Failed to send message: $e'); return MessageSendResult(success: false, errorMessage: e.toString()); } } // Upload image Future _uploadImage(File imageFile) async { try { print('[DEBUG] Starting image upload process'); // Check if file exists if (!await imageFile.exists()) { print('[ERROR] Image file does not exist: ${imageFile.path}'); throw Exception('File does not exist'); } final userId = _supabase.auth.currentUser?.id; if (userId == null) { print('[ERROR] No authenticated user found'); throw Exception('User not authenticated'); } final timestamp = DateTime.now().millisecondsSinceEpoch; final randomPart = Random().nextInt(10000).toString().padLeft(4, '0'); final filePath = '$userId-$timestamp-$randomPart.jpg'; print('[DEBUG] Generated file path: $filePath'); // Verify file size final fileSize = await imageFile.length(); print( '[DEBUG] File size: ${(fileSize / 1024 / 1024).toStringAsFixed(2)} MB', ); if (fileSize > 5 * 1024 * 1024) { // 5MB print('[ERROR] File too large: ${fileSize / 1024 / 1024} MB'); throw Exception('Ukuran gambar terlalu besar (maksimal 5MB)'); } // Try hardcoded bucket first to simplify the process try { print('[DEBUG] Attempting direct upload to images bucket'); await _supabase.storage .from('images') .upload( filePath, imageFile, fileOptions: const FileOptions( cacheControl: '3600', upsert: true, ), ); final imageUrl = _supabase.storage .from('images') .getPublicUrl(filePath); print('[DEBUG] Successfully uploaded to images bucket: $imageUrl'); return imageUrl; } catch (e) { print('[DEBUG] Direct upload to images bucket failed: $e'); // Fall back to trying multiple buckets } // Daftar bucket yang akan dicoba, dalam urutan prioritas final bucketOptions = [ 'images', 'avatars', 'community', 'chat-images', 'public', // Tambahkan bucket public jika ada ]; String? imageUrl; Exception? lastError; // Log semua bucket yang tersedia try { final buckets = await _supabase.storage.listBuckets(); print( '[DEBUG] Available buckets: ${buckets.map((b) => b.name).join(", ")}', ); // Prioritaskan bucket yang tersedia final availableBuckets = buckets.map((b) => b.name).toList(); if (availableBuckets.isNotEmpty) { // Tambahkan bucket yang tersedia di awal list bucketOptions.insertAll( 0, availableBuckets.where((name) => !bucketOptions.contains(name)), ); print('[DEBUG] Bucket order: ${bucketOptions.join(", ")}'); } } catch (e) { print('[WARNING] Failed to get bucket list: $e'); } // Try each bucket until successful with timeout for (final bucketName in bucketOptions) { try { print('[DEBUG] Attempting upload to bucket: $bucketName'); // Add timeout to prevent hanging await _supabase.storage .from(bucketName) .upload( filePath, imageFile, fileOptions: const FileOptions( cacheControl: '3600', upsert: true, ), ) .timeout( const Duration(seconds: 15), onTimeout: () { print('[WARNING] Upload to $bucketName timed out'); throw TimeoutException('Upload timed out'); }, ); // Get public URL if upload succeeds imageUrl = _supabase.storage.from(bucketName).getPublicUrl(filePath); if (imageUrl.isEmpty) { print('[WARNING] Got empty URL from storage'); throw Exception('Empty URL returned'); } // Add timestamp parameter to prevent caching final cacheBuster = DateTime.now().millisecondsSinceEpoch; imageUrl = '$imageUrl?t=$cacheBuster'; print('[SUCCESS] Upload to $bucketName successful: $imageUrl'); return imageUrl; } catch (e) { print('[DEBUG] Upload to bucket $bucketName failed: $e'); lastError = e is Exception ? e : Exception(e.toString()); // Continue to next bucket continue; } } // If all buckets failed print('[ERROR] All bucket uploads failed'); throw lastError ?? Exception('No available buckets'); } catch (e) { print('[ERROR] Image upload failed with exception: $e'); throw Exception('Failed to upload image: ${e.toString()}'); } } // Delete old messages (automatic cleanup) Future deleteOldMessages() async { try { final cutoffDate = DateTime.now().subtract(const Duration(days: 30)); final cutoffDateStr = cutoffDate.toIso8601String(); print( '[INFO] Deleting messages older than 30 days (before $cutoffDateStr)', ); final result = await _supabase .from('community_messages') .delete() .lt('created_at', cutoffDateStr) .select('id'); print('[INFO] Deleted ${result.length} old messages'); return result.length; } catch (e) { print('[ERROR] Failed to delete old messages: $e'); return 0; } } // Filter out expired messages List _filterExpiredMessages(List messages) { if (messages.isEmpty) return []; final cutoffDate = DateTime.now().subtract(const Duration(days: 30)); return messages .where((message) => message.createdAt.isAfter(cutoffDate)) .toList(); } // Process messages from database response Future> _processMessagesFromResponse(List data) async { final List messages = []; final cutoffDate = DateTime.now().subtract(const Duration(days: 30)); // Create a map of pending profile fetches to avoid duplicate requests final Map?>> pendingProfileFetches = {}; for (var item in data) { try { final senderId = item['sender_id'] as String? ?? ''; if (senderId.isEmpty) continue; // Skip old messages final messageDate = DateTime.parse( item['created_at'] ?? DateTime.now().toIso8601String(), ); if (messageDate.isBefore(cutoffDate)) continue; // Get cached user info String senderEmail = _emailCache[senderId] ?? ''; String senderUsername = _usernameCache[senderId] ?? ''; String? avatarUrl = _profilePictureCache[senderId]; // If not in cache, prepare to fetch if ((senderUsername.isEmpty || avatarUrl == null) && !pendingProfileFetches.containsKey(senderId)) { pendingProfileFetches[senderId] = _fetchUserProfile(senderId); } // Use email from message if available if (senderEmail.isEmpty && item['sender_email'] != null) { senderEmail = item['sender_email'] as String; _emailCache[senderId] = senderEmail; } // Use derived username if needed if (senderUsername.isEmpty && senderEmail.isNotEmpty) { senderUsername = senderEmail.split('@')[0]; _usernameCache[senderId] = senderUsername; } else if (senderUsername.isEmpty) { senderUsername = 'user-${senderId.substring(0, senderId.length.clamp(0, 6))}'; } // Parse reply data String? replyToId = item['reply_to_id'] as String?; String? replyToContent = item['reply_to_content'] as String?; String? replyToSenderEmail = item['reply_to_sender_email'] as String?; String? replyToSenderUsername = item['reply_to_sender_username'] as String?; // If replyToSenderUsername is not available but email is, derive username from email if (replyToSenderUsername == null && replyToSenderEmail != null && replyToSenderEmail.isNotEmpty) { replyToSenderUsername = replyToSenderEmail.split('@')[0]; } // Check if we have read receipts for this message final messageId = item['id'] as String? ?? ''; final readers = _messageReadReceipts[messageId] ?? {}; final isRead = readers.isNotEmpty; final message = Message( id: messageId, content: item['content'] as String? ?? '', senderEmail: senderEmail, senderUsername: senderUsername, senderUserId: senderId, imageUrl: item['image_url'] as String?, createdAt: messageDate, replyToId: replyToId, replyToContent: replyToContent, replyToSenderEmail: replyToSenderEmail, replyToSenderUsername: replyToSenderUsername, avatarUrl: avatarUrl, isRead: isRead, ); messages.add(message); } catch (e) { print('[ERROR] Failed to process message: $e'); } } // Sort messages by creation date (newest first) messages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); // Start fetching profiles in background _fetchProfiles(pendingProfileFetches); return messages; } // Fetch user profiles void _fetchProfiles( Map?>> pendingProfileFetches, ) { for (final entry in pendingProfileFetches.entries) { final userId = entry.key; final future = entry.value; future .then((profile) { if (profile != null) { if (profile['username'] != null) { _usernameCache[userId] = profile['username']; } if (profile['avatar_url'] != null) { _profilePictureCache[userId] = profile['avatar_url']; } } }) .catchError((_) {}); } } // Fetch a single user profile Future?> _fetchUserProfile(String userId) async { try { final profile = await _supabase .from('profiles') .select('username, avatar_url') .eq('user_id', userId) .maybeSingle(); return profile; } catch (e) { print('[ERROR] Failed to fetch user profile: $e'); return null; } } // Mark visible messages as read void markVisibleMessagesAsRead(List messages) { try { final userId = _supabase.auth.currentUser?.id; if (userId == null) return; // Kumpulkan pesan yang belum dibaca final unreadMessageIds = []; for (final message in messages) { // Skip pesan yang sudah dibaca atau pesan milik sendiri if (message.senderUserId == userId) continue; // Cek apakah pesan sudah dibaca if (!_isMessageReadByUser(message, userId)) { unreadMessageIds.add(message.id); } } // Mark messages as read using the new function if (unreadMessageIds.isNotEmpty) { markMessagesAsRead(unreadMessageIds); } } catch (e) { print('[ERROR] Error marking visible messages as read: $e'); } } // Check if a message is read by everyone bool isMessageReadByAll(Message message) { // Only check read status for own messages if (message.senderUserId != currentUserId) return false; final readers = _messageReadReceipts[message.id] ?? {}; // Get all unique user IDs (excluding self) final Set allUsers = {}; _emailCache.keys.where((id) => id != currentUserId).forEach(allUsers.add); // If no other users, consider it not read if (allUsers.isEmpty) return false; // Check if all users have read the message return readers.length >= allUsers.length; } // Mark messages as read Future markMessagesAsRead(List messageIds) async { if (messageIds.isEmpty) return; try { final userId = _supabase.auth.currentUser?.id; if (userId == null) return; print('[DEBUG] Marking ${messageIds.length} messages as read'); // Process each message ID individually using the safe function for (final messageId in messageIds) { try { await _supabase.rpc( 'add_message_read_receipt', params: {'p_message_id': messageId, 'p_user_id': userId}, ); } catch (e) { print('[ERROR] Failed to mark message $messageId as read: $e'); } } print('[DEBUG] Successfully marked messages as read'); } catch (e) { print('[ERROR] Failed to mark messages as read: $e'); } } // Mark a message as read Future markMessageAsRead(String messageId) async { if (messageId.isEmpty || _supabase.auth.currentUser == null) return; try { // Use the new function name for handling read receipts await _supabase.rpc( 'add_message_read_receipt', params: { 'p_message_id': messageId, 'p_user_id': _supabase.auth.currentUser!.id, }, ); } catch (e) { // Print error but don't throw - read status is non-critical print('[ERROR] Failed to mark message $messageId as read: $e'); } } // Cek apakah pesan sudah dibaca oleh user tertentu bool _isMessageReadByUser(Message message, String userId) { final readers = _messageReadReceipts[message.id] ?? {}; return readers.contains(userId); } // Fetch read receipts for messages Future fetchReadReceipts(List messages) async { if (messages.isEmpty) return; try { // Get all message IDs final List messageIds = messages.map((m) => m.id).toList(); // Jika terlalu banyak message ID, batasi untuk menghindari error const int maxIdsPerQuery = 50; // Proses dalam batch jika terlalu banyak for (int i = 0; i < messageIds.length; i += maxIdsPerQuery) { final int endIndex = (i + maxIdsPerQuery < messageIds.length) ? i + maxIdsPerQuery : messageIds.length; final List batchIds = messageIds.sublist(i, endIndex); try { // Fetch read receipts final response = await _supabase .from('read_receipts') .select('message_id, user_id') .filter('message_id', 'in', batchIds); // Process read receipts for (final receipt in response) { final messageId = receipt['message_id'] as String; final userId = receipt['user_id'] as String; // Update local tracking final readers = _messageReadReceipts[messageId] ?? {}; readers.add(userId); _messageReadReceipts[messageId] = readers; } print( '[DEBUG] Fetched read receipts for batch ${i ~/ maxIdsPerQuery + 1}', ); } catch (e) { print('[ERROR] Failed to fetch read receipts batch: $e'); } } } catch (e) { print('[ERROR] Failed to fetch read receipts: $e'); } } }