780 lines
24 KiB
Dart
780 lines
24 KiB
Dart
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';
|
||
|
||
class MessageLoadResult {
|
||
final List<Message> 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<String, Set<String>> _messageReadReceipts = {};
|
||
|
||
// Message-specific timers
|
||
Timer? _refreshTimer;
|
||
Timer? _readStatusTimer;
|
||
Timer? _cleanupTimer;
|
||
RealtimeChannel? _subscription;
|
||
final int _pageSize = 20;
|
||
|
||
// Cache
|
||
final Map<String, String> _emailCache = {};
|
||
final Map<String, String> _usernameCache = {};
|
||
final Map<String, String> _profilePictureCache = {};
|
||
|
||
// Getters
|
||
String? get currentUserId => _supabase.auth.currentUser?.id;
|
||
|
||
// 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?;
|
||
|
||
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,
|
||
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] ?? <String>{};
|
||
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<bool> 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<MessageLoadResult> loadMessages({
|
||
bool forceRefresh = false,
|
||
bool loadNew = false,
|
||
required List<Message> existingMessages,
|
||
}) async {
|
||
print(
|
||
'[DEBUG] Loading messages (forceRefresh: $forceRefresh, loadNew: $loadNew)',
|
||
);
|
||
|
||
try {
|
||
// Filter out expired messages
|
||
final filteredMessages = _filterExpiredMessages(existingMessages);
|
||
|
||
// If loading new messages and we have existing messages
|
||
if (loadNew && filteredMessages.isNotEmpty) {
|
||
final newestTimestamp =
|
||
filteredMessages.first.createdAt.toIso8601String();
|
||
final response = await _supabase
|
||
.from('community_messages')
|
||
.select('*')
|
||
.gte('created_at', newestTimestamp)
|
||
.order('created_at', ascending: false);
|
||
|
||
print('[DEBUG] Got [38;5;5m');
|
||
|
||
final newMessages = await _processMessagesFromResponse(response);
|
||
return MessageLoadResult(messages: newMessages, hasMore: true);
|
||
}
|
||
|
||
// Initial load or refresh, get the first page
|
||
final response = await _supabase
|
||
.from('community_messages')
|
||
.select('*')
|
||
.order('created_at', ascending: false)
|
||
.limit(_pageSize);
|
||
|
||
print('[DEBUG] Got [38;5;5m');
|
||
|
||
final messages = await _processMessagesFromResponse(response);
|
||
return MessageLoadResult(
|
||
messages: messages,
|
||
hasMore: messages.length >= _pageSize,
|
||
);
|
||
} catch (e) {
|
||
print('[ERROR] Failed to load messages: $e');
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
// Load more messages (pagination)
|
||
Future<MessageLoadResult> loadMoreMessages(
|
||
List<Message> existingMessages,
|
||
) async {
|
||
if (existingMessages.isEmpty) {
|
||
return MessageLoadResult(messages: [], hasMore: false);
|
||
}
|
||
|
||
try {
|
||
// Get the oldest message timestamp
|
||
final oldestMessageDate =
|
||
existingMessages.last.createdAt.toIso8601String();
|
||
|
||
// Query messages older than the oldest message we have
|
||
final response = await _supabase
|
||
.from('community_messages')
|
||
.select('*')
|
||
.lt('created_at', oldestMessageDate)
|
||
.order('created_at', ascending: false)
|
||
.limit(_pageSize);
|
||
|
||
final newMessages = await _processMessagesFromResponse(response);
|
||
|
||
// Check if we have more messages to load
|
||
final hasMore = newMessages.length >= _pageSize;
|
||
|
||
return MessageLoadResult(messages: newMessages, hasMore: hasMore);
|
||
} catch (e) {
|
||
print('[ERROR] Failed to load more messages: $e');
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
// Search messages
|
||
Future<List<Message>> searchMessages(String query) async {
|
||
try {
|
||
// Use case-insensitive search
|
||
final response = await _supabase
|
||
.from('community_messages')
|
||
.select('*')
|
||
.ilike('content', '%$query%')
|
||
.order('created_at', ascending: false)
|
||
.limit(50);
|
||
|
||
print('[DEBUG] Found ${response.length} search results');
|
||
|
||
return _processMessagesFromResponse(response);
|
||
} catch (e) {
|
||
print('[ERROR] Failed to search messages: $e');
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
// Send message
|
||
Future<MessageSendResult> sendMessage({
|
||
required String? text,
|
||
required File? imageFile,
|
||
required Message? replyToMessage,
|
||
required String? currentUsername,
|
||
required String? currentEmail,
|
||
required Function(Message) onOptimisticUpdate,
|
||
}) async {
|
||
final messageText = text?.trim() ?? '';
|
||
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');
|
||
|
||
// Upload image if available
|
||
String? imageUrl;
|
||
if (imageFile != null) {
|
||
print('[DEBUG] Uploading image for message: $messageId');
|
||
imageUrl = await _uploadImage(imageFile);
|
||
print('[DEBUG] Image uploaded: $imageUrl');
|
||
}
|
||
|
||
// 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,
|
||
avatarUrl: _profilePictureCache[userId],
|
||
);
|
||
|
||
// Trigger optimistic update
|
||
onOptimisticUpdate(optimisticMessage);
|
||
|
||
// Prepare message data
|
||
final messageData = optimisticMessage.toMap();
|
||
|
||
// Insert to database
|
||
print('[DEBUG] Saving message to database');
|
||
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<String, dynamic>.from(messageData);
|
||
retryData.remove('reply_to_id');
|
||
retryData.remove('reply_to_content');
|
||
retryData.remove('reply_to_sender_email');
|
||
|
||
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');
|
||
rethrow;
|
||
}
|
||
} else {
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
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<String?> _uploadImage(File imageFile) async {
|
||
try {
|
||
final userId = _supabase.auth.currentUser!.id;
|
||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||
final filePath = '$userId-$timestamp.jpg';
|
||
|
||
// Upload to 'chat-images' bucket
|
||
await _supabase.storage.from('chat-images').upload(filePath, imageFile);
|
||
|
||
// Get public URL
|
||
final imageUrl = _supabase.storage
|
||
.from('chat-images')
|
||
.getPublicUrl(filePath);
|
||
|
||
return imageUrl;
|
||
} catch (e) {
|
||
print('[ERROR] Failed to upload image: $e');
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
// Delete old messages (automatic cleanup)
|
||
Future<int> 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<Message> _filterExpiredMessages(List<Message> 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<List<Message>> _processMessagesFromResponse(List<dynamic> data) async {
|
||
final List<Message> messages = [];
|
||
final cutoffDate = DateTime.now().subtract(const Duration(days: 30));
|
||
|
||
// Create a map of pending profile fetches to avoid duplicate requests
|
||
final Map<String, Future<Map<String, dynamic>?>> 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?;
|
||
|
||
// Check if we have read receipts for this message
|
||
final messageId = item['id'] as String? ?? '';
|
||
final readers = _messageReadReceipts[messageId] ?? <String>{};
|
||
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,
|
||
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<String, Future<Map<String, dynamic>?>> 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<Map<String, dynamic>?> _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;
|
||
}
|
||
}
|
||
|
||
// Read status handling
|
||
void markVisibleMessagesAsRead(List<Message> visibleMessages) {
|
||
if (visibleMessages.isEmpty || currentUserId == null) return;
|
||
|
||
final messagesToUpdate = <String>[];
|
||
|
||
// Find messages that aren't from current user and aren't marked as read
|
||
for (final message in visibleMessages) {
|
||
// Skip own messages
|
||
if (message.senderUserId == currentUserId) continue;
|
||
|
||
// Check if user has already read the message
|
||
final readers = _messageReadReceipts[message.id] ?? <String>{};
|
||
if (!readers.contains(currentUserId)) {
|
||
messagesToUpdate.add(message.id);
|
||
|
||
// Update local read status
|
||
readers.add(currentUserId!);
|
||
_messageReadReceipts[message.id] = readers;
|
||
}
|
||
}
|
||
|
||
// Update read status in database
|
||
if (messagesToUpdate.isNotEmpty) {
|
||
_updateReadStatusInDatabase(messagesToUpdate, currentUserId!);
|
||
}
|
||
}
|
||
|
||
Future<void> _updateReadStatusInDatabase(
|
||
List<String> messageIds,
|
||
String userId,
|
||
) async {
|
||
if (messageIds.isEmpty) return;
|
||
|
||
try {
|
||
// Check if table exists
|
||
bool tableExists = false;
|
||
try {
|
||
await _supabase.from('message_read_receipts').select('count').limit(1);
|
||
tableExists = true;
|
||
} catch (e) {
|
||
print('[INFO] Read receipts table might not exist: $e');
|
||
}
|
||
|
||
if (tableExists) {
|
||
// Prepare batch of read receipts
|
||
final List<Map<String, dynamic>> readReceipts = [];
|
||
|
||
for (final messageId in messageIds) {
|
||
readReceipts.add({
|
||
'message_id': messageId,
|
||
'user_id': userId,
|
||
'read_at': DateTime.now().toIso8601String(),
|
||
});
|
||
}
|
||
|
||
// Insert read receipts
|
||
await _supabase
|
||
.from('message_read_receipts')
|
||
.upsert(readReceipts, onConflict: 'message_id,user_id');
|
||
|
||
print('[DEBUG] Updated read status for ${messageIds.length} messages');
|
||
} else {
|
||
print("[INFO] Read receipts table doesn't exist, skipping update");
|
||
}
|
||
} catch (e) {
|
||
print('[ERROR] Failed to update read status: $e');
|
||
}
|
||
}
|
||
|
||
Future<void> fetchReadReceipts(List<Message> messages) async {
|
||
if (messages.isEmpty) return;
|
||
|
||
try {
|
||
// Check if table exists
|
||
bool tableExists = false;
|
||
try {
|
||
await _supabase.from('message_read_receipts').select('count').limit(1);
|
||
tableExists = true;
|
||
} catch (e) {
|
||
print('[INFO] Read receipts table might not exist: $e');
|
||
return;
|
||
}
|
||
|
||
if (tableExists) {
|
||
// Get all message IDs
|
||
final List<String> messageIds = messages.map((m) => m.id).toList();
|
||
|
||
// Fetch read receipts
|
||
final response = await _supabase
|
||
.from('message_read_receipts')
|
||
.select('message_id, user_id')
|
||
.filter('message_id', 'in', messageIds);
|
||
|
||
// 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] ?? <String>{};
|
||
readers.add(userId);
|
||
_messageReadReceipts[messageId] = readers;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
print('[ERROR] Failed to fetch read receipts: $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] ?? <String>{};
|
||
|
||
// Get all unique user IDs (excluding self)
|
||
final Set<String> 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;
|
||
}
|
||
}
|