717 lines
24 KiB
Dart
717 lines
24 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:convert';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/models/group_message.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
import 'dart:math';
|
|
|
|
class MessageSendResult {
|
|
final bool success;
|
|
final String? errorMessage;
|
|
final GroupMessage? message;
|
|
|
|
MessageSendResult({required this.success, this.errorMessage, this.message});
|
|
}
|
|
|
|
class GroupMessageService {
|
|
final _supabase = Supabase.instance.client;
|
|
final _uuid = Uuid();
|
|
Timer? _refreshTimer;
|
|
Timer? _readStatusTimer;
|
|
RealtimeChannel? _messagesChannel;
|
|
final Map<String, String> _profilePictureCache = {};
|
|
|
|
String? get currentUserId => _supabase.auth.currentUser?.id;
|
|
|
|
// Load messages for a specific group
|
|
Future<MessageResult> loadMessages(
|
|
String groupId, {
|
|
bool forceRefresh = false,
|
|
bool loadNew = false,
|
|
List<GroupMessage> existingMessages = const [],
|
|
}) async {
|
|
try {
|
|
print(
|
|
'[FORCE] Loading messages for group: $groupId, forceRefresh: $forceRefresh, loadNew: $loadNew',
|
|
);
|
|
|
|
// Use a much shorter timeout
|
|
const timeout = Duration(seconds: 3);
|
|
|
|
// More aggressive approach: Try direct query with different options
|
|
List<dynamic> response = [];
|
|
Exception? lastError;
|
|
bool success = false;
|
|
|
|
// First attempt - Use RPC function that bypasses RLS
|
|
try {
|
|
response = await _supabase
|
|
.rpc('get_messages_for_group', params: {'group_id_param': groupId})
|
|
.timeout(timeout);
|
|
|
|
success = true;
|
|
print(
|
|
'[FORCE] First query attempt successful: ${response.length} messages',
|
|
);
|
|
} catch (e) {
|
|
lastError = e is Exception ? e : Exception(e.toString());
|
|
print('[FORCE] First query attempt failed: $e');
|
|
|
|
// Fall back to simple query
|
|
try {
|
|
response = await _supabase
|
|
.from('messages')
|
|
.select()
|
|
.eq('group_id', groupId)
|
|
.order('created_at', ascending: false)
|
|
.limit(20)
|
|
.timeout(timeout);
|
|
|
|
success = true;
|
|
print(
|
|
'[FORCE] Simplified query successful: ${response.length} messages',
|
|
);
|
|
} catch (e2) {
|
|
print('[FORCE] Simplified query failed: $e2');
|
|
}
|
|
}
|
|
|
|
// If no messages found for default group, create a test message
|
|
if (success &&
|
|
response.isEmpty &&
|
|
groupId == '00000000-0000-0000-0000-000000000001') {
|
|
print(
|
|
'[AUTO] No messages found for default group, creating test message',
|
|
);
|
|
try {
|
|
// Get current user
|
|
final userId = currentUserId;
|
|
if (userId != null) {
|
|
// Try to get username from profiles
|
|
String username = 'User';
|
|
String email = '';
|
|
|
|
try {
|
|
final profileData =
|
|
await _supabase
|
|
.from('profiles')
|
|
.select('username, email')
|
|
.eq('user_id', userId)
|
|
.single();
|
|
|
|
username = profileData['username'] ?? 'User';
|
|
email = profileData['email'] ?? '';
|
|
} catch (e) {
|
|
print('[ERROR] Failed to get profile: $e');
|
|
}
|
|
|
|
// Insert test welcome message
|
|
final messageId = _uuid.v4();
|
|
final messageData = {
|
|
'id': messageId,
|
|
'content':
|
|
'Selamat datang di Grup TaniSM4RT! Ini adalah pesan otomatis. Silakan memulai percakapan.',
|
|
'sender_user_id': userId,
|
|
'sender_username': username,
|
|
'sender_email': email,
|
|
'group_id': groupId,
|
|
'created_at': DateTime.now().toIso8601String(),
|
|
};
|
|
|
|
// Insert into database
|
|
final result =
|
|
await _supabase.from('messages').insert(messageData).select();
|
|
print(
|
|
'[AUTO] Created test message: ${result.isNotEmpty ? "Success" : "Failed"}',
|
|
);
|
|
|
|
// Add to response
|
|
if (result.isNotEmpty) {
|
|
response = result;
|
|
}
|
|
|
|
// Force reload after short delay
|
|
Future.delayed(Duration(milliseconds: 300), () {
|
|
loadMessages(groupId, forceRefresh: true);
|
|
});
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Failed to create test message: $e');
|
|
}
|
|
}
|
|
|
|
print(
|
|
'[FORCE] Successfully loaded ${response.length} messages from database',
|
|
);
|
|
final messages = _parseMessages(response);
|
|
return MessageResult(messages: messages, hasMore: messages.length == 20);
|
|
} catch (e) {
|
|
print('[ERROR] Failed to load messages after multiple attempts: $e');
|
|
return MessageResult(messages: [], hasMore: false);
|
|
}
|
|
}
|
|
|
|
// Load more messages (pagination)
|
|
Future<MessageResult> loadMoreMessages(
|
|
String groupId,
|
|
List<GroupMessage> existingMessages,
|
|
) async {
|
|
try {
|
|
if (existingMessages.isEmpty) {
|
|
return await loadMessages(groupId);
|
|
}
|
|
|
|
const limit = 20;
|
|
|
|
// Use the messages_with_sender view
|
|
final response = await _supabase
|
|
.from('messages_with_sender')
|
|
.select()
|
|
.eq('group_id', groupId)
|
|
.order('created_at', ascending: false)
|
|
.limit(limit + existingMessages.length);
|
|
|
|
final allMessages = _parseMessages(response);
|
|
|
|
// Filter out messages we already have (client-side)
|
|
final existingIds = existingMessages.map((m) => m.id).toSet();
|
|
final newMessages =
|
|
allMessages.where((m) => !existingIds.contains(m.id)).toList();
|
|
|
|
return MessageResult(
|
|
messages: newMessages,
|
|
hasMore: newMessages.length == limit,
|
|
);
|
|
} catch (e) {
|
|
print('[ERROR] Failed to load more messages: $e');
|
|
return MessageResult(messages: [], hasMore: false);
|
|
}
|
|
}
|
|
|
|
// Parse messages from database response
|
|
List<GroupMessage> _parseMessages(List<dynamic> response) {
|
|
return response
|
|
.map((data) {
|
|
// Check if this is a JSON string from RPC function
|
|
final Map<String, dynamic> messageData;
|
|
|
|
if (data is String) {
|
|
// Parse JSON string from RPC function
|
|
try {
|
|
messageData = jsonDecode(data);
|
|
} catch (e) {
|
|
print('[ERROR] Failed to parse message JSON: $e');
|
|
return null;
|
|
}
|
|
} else if (data is Map) {
|
|
// Direct database response
|
|
messageData = Map<String, dynamic>.from(data);
|
|
} else {
|
|
print('[ERROR] Unknown message data format: ${data.runtimeType}');
|
|
return null;
|
|
}
|
|
|
|
// Create message from map
|
|
try {
|
|
return GroupMessage.fromMap({
|
|
'id': messageData['id'],
|
|
'content': messageData['content'] ?? '',
|
|
'sender_user_id': messageData['sender_user_id'],
|
|
'sender_username':
|
|
messageData['profile_username'] ??
|
|
messageData['sender_username'] ??
|
|
'',
|
|
'sender_email':
|
|
messageData['profile_email'] ??
|
|
messageData['sender_email'] ??
|
|
'',
|
|
'created_at': messageData['created_at'],
|
|
'image_url': messageData['image_url'],
|
|
'reply_to_id': messageData['reply_to_id'],
|
|
'reply_to_content': messageData['reply_to_content'],
|
|
'reply_to_sender_email': messageData['reply_to_sender_email'],
|
|
'avatar_url': messageData['avatar_url'],
|
|
'group_id': messageData['group_id'],
|
|
});
|
|
} catch (e) {
|
|
print('[ERROR] Failed to create message from data: $e');
|
|
return null;
|
|
}
|
|
})
|
|
.where((message) => message != null)
|
|
.cast<GroupMessage>()
|
|
.toList();
|
|
}
|
|
|
|
// Send message to group
|
|
Future<MessageSendResult> sendMessage({
|
|
required String groupId,
|
|
String? text,
|
|
File? imageFile,
|
|
GroupMessage? replyToMessage,
|
|
String? currentUsername,
|
|
String? currentEmail,
|
|
Function(GroupMessage)? 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 messageId = _uuid.v4(); // UUID valid
|
|
|
|
print('[DEBUG] Sending group message: $messageId');
|
|
print(
|
|
'[DEBUG] Group message text: "$messageText", has image: ${imageFile != null}',
|
|
);
|
|
|
|
// Prepare message data
|
|
final messageData = {
|
|
'id': messageId,
|
|
'content': messageText, // Always include content even if empty
|
|
'sender_email': userEmail,
|
|
'sender_username': currentUsername ?? userEmail.split('@')[0],
|
|
'sender_user_id': userId,
|
|
'group_id': groupId,
|
|
'created_at': DateTime.now().toIso8601String(),
|
|
};
|
|
|
|
// Add reply data if replying to a message
|
|
if (replyToMessage != null) {
|
|
messageData['reply_to_id'] = replyToMessage.id;
|
|
messageData['reply_to_content'] = replyToMessage.content;
|
|
messageData['reply_to_sender_email'] = replyToMessage.senderEmail;
|
|
messageData['reply_to_sender_username'] = replyToMessage.senderUsername;
|
|
}
|
|
|
|
// Create an optimistic message object for immediate UI update
|
|
GroupMessage optimisticMessage;
|
|
|
|
// For image uploads, create a special optimistic message with local image file
|
|
if (imageFile != null) {
|
|
optimisticMessage = GroupMessage.fromMap({
|
|
...messageData,
|
|
'created_at': DateTime.now().toIso8601String(),
|
|
'isLocalImage': true,
|
|
'localImageFile': imageFile,
|
|
'content': messageText, // Ensure content is preserved
|
|
});
|
|
} else {
|
|
optimisticMessage = GroupMessage.fromMap({
|
|
...messageData,
|
|
'created_at': DateTime.now().toIso8601String(),
|
|
});
|
|
}
|
|
|
|
// Call the optimistic update function if provided
|
|
if (onOptimisticUpdate != null) {
|
|
onOptimisticUpdate(optimisticMessage);
|
|
}
|
|
|
|
// If there's an image, upload it first
|
|
if (imageFile != null) {
|
|
try {
|
|
final imageUrl = await _uploadImage(imageFile);
|
|
if (imageUrl != null) {
|
|
messageData['image_url'] = imageUrl;
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Failed to upload image: $e');
|
|
return MessageSendResult(
|
|
success: false,
|
|
errorMessage: 'Gagal mengunggah gambar: ${e.toString()}',
|
|
);
|
|
}
|
|
}
|
|
|
|
print(
|
|
'[DEBUG] Saving group message to database: ${messageData.toString()}',
|
|
);
|
|
bool saveSuccess = false;
|
|
|
|
try {
|
|
// First try with all data including reply fields
|
|
await _supabase.from('messages').insert(messageData);
|
|
print('[DEBUG] Group message saved successfully');
|
|
saveSuccess = true;
|
|
} catch (e) {
|
|
print('[ERROR] Failed to save group 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');
|
|
retryData.remove('reply_to_sender_username');
|
|
|
|
try {
|
|
await _supabase.from('messages').insert(retryData);
|
|
print('[DEBUG] Group 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 success
|
|
return MessageSendResult(
|
|
success: saveSuccess,
|
|
message: optimisticMessage,
|
|
);
|
|
} catch (e) {
|
|
print('[ERROR] Failed to send group message: $e');
|
|
return MessageSendResult(success: false, errorMessage: e.toString());
|
|
}
|
|
}
|
|
|
|
// Delete a message
|
|
Future<bool> deleteMessage(GroupMessage message) async {
|
|
try {
|
|
await _supabase.from('messages').delete().eq('id', message.id);
|
|
return true;
|
|
} catch (e) {
|
|
print('[ERROR] Failed to delete message: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Real-time subscription
|
|
void setupMessagesSubscription(
|
|
String groupId,
|
|
Function(GroupMessage) onNewMessage,
|
|
Function(String, String) onReadStatusUpdate,
|
|
) async {
|
|
try {
|
|
print('[STREAM] Setting up real-time subscription for group: $groupId');
|
|
|
|
// Unsubscribe from any existing subscription
|
|
_messagesChannel?.unsubscribe();
|
|
_messagesChannel = null;
|
|
|
|
// Set up new subscription
|
|
_messagesChannel = _supabase
|
|
.channel('public:messages')
|
|
.onPostgresChanges(
|
|
event: PostgresChangeEvent.insert,
|
|
schema: 'public',
|
|
table: 'messages', // Listen specifically to the messages table
|
|
filter: PostgresChangeFilter(
|
|
type: PostgresChangeFilterType.eq,
|
|
column: 'group_id',
|
|
value: groupId,
|
|
),
|
|
callback: (payload) async {
|
|
try {
|
|
print(
|
|
'[STREAM] New message received: ${payload.newRecord['id']}',
|
|
);
|
|
|
|
// Skip our own messages as we already show them optimistically
|
|
final messageUserId =
|
|
payload.newRecord['sender_user_id'] as String?;
|
|
if (messageUserId == currentUserId) {
|
|
print(
|
|
'[STREAM] Skipping own message in real-time: ${payload.newRecord['id']}',
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Get complete message data
|
|
final messageId = payload.newRecord['id'] as String;
|
|
|
|
// Get the full message data with user info
|
|
final response =
|
|
await _supabase
|
|
.from('messages')
|
|
.select('*, profiles:sender_user_id(*)')
|
|
.eq('id', messageId)
|
|
.single();
|
|
|
|
// Create full message data with profile info
|
|
final fullMessageData = {
|
|
'id': response['id'],
|
|
'content': response['content'] ?? '',
|
|
'sender_user_id': response['sender_user_id'],
|
|
'sender_username':
|
|
response['profiles']?['username'] ??
|
|
response['sender_username'] ??
|
|
'',
|
|
'sender_email':
|
|
response['profiles']?['email'] ??
|
|
response['sender_email'] ??
|
|
'',
|
|
'created_at': response['created_at'],
|
|
'image_url': response['image_url'],
|
|
'reply_to_id': response['reply_to_id'],
|
|
'reply_to_content': response['reply_to_content'],
|
|
'reply_to_sender_email': response['reply_to_sender_email'],
|
|
'avatar_url': response['profiles']?['avatar_url'],
|
|
'group_id': response['group_id'],
|
|
};
|
|
|
|
// Add force refresh timestamp to image URL to prevent caching
|
|
if (fullMessageData['image_url'] != null &&
|
|
fullMessageData['image_url'].toString().isNotEmpty) {
|
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
|
fullMessageData['image_url'] =
|
|
'${fullMessageData['image_url']}?t=$timestamp';
|
|
}
|
|
|
|
final message = GroupMessage.fromMap(fullMessageData);
|
|
|
|
// Call the callback
|
|
onNewMessage(message);
|
|
|
|
print('[STREAM] Real-time message processed: ${message.id}');
|
|
} catch (e) {
|
|
print('[ERROR] Failed to process real-time message: $e');
|
|
}
|
|
},
|
|
)
|
|
.onPostgresChanges(
|
|
event: PostgresChangeEvent.update,
|
|
schema: 'public',
|
|
table: 'read_receipts', // Use read_receipts for message read status
|
|
callback: (payload) {
|
|
final messageId = payload.newRecord['message_id'] as String?;
|
|
final userId = payload.newRecord['user_id'] as String?;
|
|
|
|
if (messageId != null && userId != null) {
|
|
onReadStatusUpdate(messageId, userId);
|
|
print(
|
|
'[STREAM] Read status updated: message=$messageId, user=$userId',
|
|
);
|
|
}
|
|
},
|
|
);
|
|
_messagesChannel!.subscribe(); // Subscribe to the channel
|
|
|
|
print('[STREAM] Subscription set up successfully');
|
|
|
|
// Check if there are any messages via direct query
|
|
try {
|
|
final count =
|
|
await _supabase
|
|
.from('messages')
|
|
.select('count')
|
|
.eq('group_id', groupId)
|
|
.single();
|
|
|
|
final messageCount = count['count'] as int? ?? 0;
|
|
print('[DEBUG] No messages found on initial subscription load');
|
|
} catch (e) {
|
|
print('[ERROR] Error checking message count: $e');
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Failed to set up message subscription: $e');
|
|
}
|
|
}
|
|
|
|
// Mark messages as read
|
|
Future<void> markMessagesAsRead(List<GroupMessage> messages) async {
|
|
if (messages.isEmpty || currentUserId == null) return;
|
|
|
|
try {
|
|
// Filter out messages that are already read by current user
|
|
final unreadMessages = messages.where((msg) => !msg.isRead).toList();
|
|
|
|
if (unreadMessages.isEmpty) return;
|
|
|
|
// Process each message ID individually using the new function
|
|
for (final message in unreadMessages) {
|
|
try {
|
|
// Pastikan ID pesan adalah UUID yang valid
|
|
final messageId = message.id;
|
|
if (messageId.isEmpty) continue;
|
|
|
|
// Use the new function name for handling read receipts
|
|
await _supabase
|
|
.rpc(
|
|
'add_message_read_receipt',
|
|
params: {
|
|
'p_message_id': messageId.toString(), // Ensure it's string
|
|
'p_user_id': currentUserId.toString(), // Ensure it's string
|
|
},
|
|
)
|
|
.timeout(
|
|
Duration(seconds: 2),
|
|
onTimeout: () {
|
|
print(
|
|
'[WARNING] Timeout marking message as read: $messageId',
|
|
);
|
|
return null;
|
|
},
|
|
);
|
|
} catch (e) {
|
|
print('[ERROR] Failed to mark message ${message.id} as read: $e');
|
|
// Lanjutkan ke pesan berikutnya, jangan biarkan satu error menghentikan semua
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Failed to mark messages as read: $e');
|
|
}
|
|
}
|
|
|
|
// Check if a message has been read by all users in the group
|
|
bool isMessageReadByAll(GroupMessage message) {
|
|
// For now, just check if the current user has read it
|
|
// In a real app, you'd check against all group members
|
|
return message.isRead;
|
|
}
|
|
|
|
// Clean up resources
|
|
void dispose() {
|
|
// Clean up subscriptions
|
|
_messagesChannel?.unsubscribe();
|
|
_messagesChannel = null;
|
|
_readStatusTimer?.cancel();
|
|
// _messageStreamController.close(); // This line was removed as per the new_code
|
|
}
|
|
|
|
// Upload image
|
|
Future<String?> _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;
|
|
|
|
// Coba setiap bucket sampai berhasil
|
|
for (final bucketName in bucketOptions) {
|
|
try {
|
|
print('[DEBUG] Trying upload to bucket: $bucketName');
|
|
|
|
await _supabase.storage
|
|
.from(bucketName)
|
|
.upload(
|
|
filePath,
|
|
imageFile,
|
|
fileOptions: const FileOptions(
|
|
cacheControl: '3600',
|
|
upsert: true,
|
|
),
|
|
);
|
|
|
|
imageUrl = _supabase.storage.from(bucketName).getPublicUrl(filePath);
|
|
print(
|
|
'[DEBUG] Successfully uploaded to bucket $bucketName: $imageUrl',
|
|
);
|
|
return imageUrl;
|
|
} catch (e) {
|
|
print('[DEBUG] Failed to upload to bucket $bucketName: $e');
|
|
lastError = e as Exception;
|
|
// Continue to next bucket
|
|
}
|
|
}
|
|
|
|
// If all buckets failed
|
|
if (lastError != null) {
|
|
throw lastError;
|
|
}
|
|
return null;
|
|
} catch (e) {
|
|
print('[ERROR] Image upload failed: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper class to return messages with pagination info
|
|
class MessageResult {
|
|
final List<GroupMessage> messages;
|
|
final bool hasMore;
|
|
|
|
MessageResult({required this.messages, required this.hasMore});
|
|
}
|