Compare commits

..

2 Commits

Author SHA1 Message Date
LittleSheep
6892afb974 🔊 Add more logging and optimzation 2025-08-16 23:39:41 +08:00
LittleSheep
007b46b080 ♻️ Refactored the message repository logic 2025-08-16 23:07:21 +08:00
4 changed files with 845 additions and 1064 deletions

View File

@@ -1,484 +0,0 @@
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<String, LocalChatMessage> pendingMessages = {};
final Map<String, Map<int, double>> fileUploadProgress = {};
int? _totalCount;
MessageRepository(this.room, this.identity, this._apiClient, this._database);
Future<LocalChatMessage?> getLastMessages() async {
final dbMessages = await _database.getMessagesForRoom(
room.id,
offset: 0,
limit: 1,
);
if (dbMessages.isEmpty) {
return null;
}
return _database.companionToMessage(dbMessages.first);
}
Future<bool> syncMessages() async {
final lastMessage = await getLastMessages();
if (lastMessage == null) return false;
try {
final resp = await _apiClient.post(
'/sphere/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<List<LocalChatMessage>> listMessages({
int offset = 0,
int take = 20,
bool synced = false,
}) async {
try {
// For initial load, fetch latest messages in the background to sync.
if (offset == 0 && !synced) {
// Not awaiting this is intentional, for a quicker UI response.
// The UI should rely on a stream from the database to get updates.
_fetchAndCacheMessages(room.id, offset: 0, take: take).catchError((_) {
// Best effort, errors will be handled by later fetches.
return <LocalChatMessage>[];
});
}
final localMessages = await _getCachedMessages(
room.id,
offset: offset,
take: take,
);
// If local cache has messages, return them. This is the common case for scrolling up.
if (localMessages.isNotEmpty) {
return localMessages;
}
// If local cache is empty, we've probably reached the end of cached history.
// Fetch from remote. This will also be hit on first load if cache is empty.
return await _fetchAndCacheMessages(room.id, offset: offset, take: take);
} catch (e) {
// Final fallback to cache in case of network errors during fetch.
final localMessages = await _getCachedMessages(
room.id,
offset: offset,
take: take,
);
if (localMessages.isNotEmpty) {
return localMessages;
}
rethrow;
}
}
Future<List<LocalChatMessage>> _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 for the first page
if (offset == 0) {
final pendingForRoom =
pendingMessages.values.where((msg) => msg.roomId == roomId).toList();
final allMessages = [...pendingForRoom, ...dbLocalMessages];
allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
// Remove duplicates by ID, preserving the order
final uniqueMessages = <LocalChatMessage>[];
final seenIds = <String>{};
for (final message in allMessages) {
if (seenIds.add(message.id)) {
uniqueMessages.add(message);
}
}
return uniqueMessages;
}
return dbLocalMessages;
}
Future<List<LocalChatMessage>> _fetchAndCacheMessages(
String roomId, {
int offset = 0,
int take = 20,
}) async {
// Use cached total count if available, otherwise fetch it
if (_totalCount == null) {
final response = await _apiClient.get(
'/sphere/chat/$roomId/messages',
queryParameters: {'offset': 0, 'take': 1},
);
_totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0');
}
if (offset >= _totalCount!) {
return [];
}
final response = await _apiClient.get(
'/sphere/chat/$roomId/messages',
queryParameters: {'offset': offset, 'take': take},
);
final List<dynamic> data = response.data;
// Update total count from response headers
_totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0');
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<LocalChatMessage> sendMessage(
String token,
String baseUrl,
String roomId,
String content,
String nonce, {
required List<UniversalFile> attachments,
Map<String, dynamic>? meta,
SnChatMessage? replyingTo,
SnChatMessage? forwardingTo,
SnChatMessage? editingTo,
Function(LocalChatMessage)? onPending,
Function(String, Map<int, double>)? 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],
atk: token,
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
? '/sphere/chat/$roomId/messages'
: '/sphere/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<LocalChatMessage> retryMessage(String pendingMessageId) async {
final message = await getMessageById(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(
'/sphere/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<LocalChatMessage> 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<LocalChatMessage> receiveMessageUpdate(
SnChatMessage remoteMessage,
) async {
final localMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
);
await _database.updateMessage(_database.messageToCompanion(localMessage));
return localMessage;
}
Future<void> receiveMessageDeletion(String messageId) async {
// Remove from pending messages if exists
pendingMessages.remove(messageId);
// Delete from local database
await _database.deleteMessage(messageId);
}
Future<LocalChatMessage> updateMessage(
String messageId,
String content, {
List<SnCloudFile>? attachments,
Map<String, dynamic>? 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(
'/sphere/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<void> deleteMessage(String messageId) async {
try {
await _apiClient.delete('/sphere/chat/${room.id}/messages/$messageId');
pendingMessages.remove(messageId);
await _database.deleteMessage(messageId);
} catch (e) {
rethrow;
}
}
Future<LocalChatMessage?> 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(
'/sphere/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;
}
}
}

View File

@@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -10,14 +12,15 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:island/database/drift_db.dart';
import 'package:island/database/message.dart'; import 'package:island/database/message.dart';
import 'package:island/database/message_repository.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/database.dart'; import 'package:island/pods/database.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/services/file.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
@@ -39,17 +42,46 @@ import 'package:island/widgets/stickers/picker.dart';
part 'room.g.dart'; part 'room.g.dart';
final messageRepositoryProvider = final isSyncingProvider = StateProvider.autoDispose<bool>((ref) => false);
FutureProvider.family<MessageRepository, String>((ref, roomId) async {
final room = await ref.watch(chatroomProvider(roomId).future); final appLifecycleStateProvider = StreamProvider<AppLifecycleState>((ref) {
final identity = await ref.watch(chatroomIdentityProvider(roomId).future); final controller = StreamController<AppLifecycleState>();
final apiClient = ref.watch(apiClientProvider);
final database = ref.watch(databaseProvider); final observer = _AppLifecycleObserver((state) {
return MessageRepository(room!, identity!, apiClient, database); if (controller.isClosed) return;
controller.add(state);
}); });
WidgetsBinding.instance.addObserver(observer);
ref.onDispose(() {
WidgetsBinding.instance.removeObserver(observer);
controller.close();
});
return controller.stream;
});
class _AppLifecycleObserver extends WidgetsBindingObserver {
final ValueChanged<AppLifecycleState> onChange;
_AppLifecycleObserver(this.onChange);
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
onChange(state);
}
}
@riverpod @riverpod
class MessagesNotifier extends _$MessagesNotifier { class MessagesNotifier extends _$MessagesNotifier {
late final Dio _apiClient;
late final AppDatabase _database;
late final SnChatRoom _room;
late final SnChatMember _identity;
final Map<String, LocalChatMessage> _pendingMessages = {};
final Map<String, Map<int, double>> _fileUploadProgress = {};
int? _totalCount;
late final String _roomId; late final String _roomId;
int _currentPage = 0; int _currentPage = 0;
static const int _pageSize = 20; static const int _pageSize = 20;
@@ -58,38 +90,209 @@ class MessagesNotifier extends _$MessagesNotifier {
@override @override
FutureOr<List<LocalChatMessage>> build(String roomId) async { FutureOr<List<LocalChatMessage>> build(String roomId) async {
_roomId = roomId; _roomId = roomId;
_apiClient = ref.watch(apiClientProvider);
_database = ref.watch(databaseProvider);
final room = await ref.watch(chatroomProvider(roomId).future);
final identity = await ref.watch(chatroomIdentityProvider(roomId).future);
if (room == null || identity == null) {
throw Exception('Room or identity not found');
}
_room = room;
_identity = identity;
developer.log('MessagesNotifier built for room $roomId', name: 'MessagesNotifier');
ref.listen(appLifecycleStateProvider, (_, next) {
if (next.hasValue && next.value == AppLifecycleState.resumed) {
developer.log('App resumed, syncing messages', name: 'MessagesNotifier');
syncMessages();
}
});
return await loadInitial(); return await loadInitial();
} }
Future<List<LocalChatMessage>> loadInitial() async { Future<List<LocalChatMessage>> _getCachedMessages({
try { int offset = 0,
final repository = await ref.read( int take = 20,
messageRepositoryProvider(_roomId).future, }) async {
developer.log('Getting cached messages from offset $offset, take $take', name: 'MessagesNotifier');
final dbMessages = await _database.getMessagesForRoom(
_roomId,
offset: offset,
limit: take,
); );
final synced = await repository.syncMessages(); final dbLocalMessages =
final messages = await repository.listMessages( dbMessages.map(_database.companionToMessage).toList();
offset: 0,
take: _pageSize, if (offset == 0) {
synced: synced, final pendingForRoom =
_pendingMessages.values.where((msg) => msg.roomId == _roomId).toList();
final allMessages = [...pendingForRoom, ...dbLocalMessages];
allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
final uniqueMessages = <LocalChatMessage>[];
final seenIds = <String>{};
for (final message in allMessages) {
if (seenIds.add(message.id)) {
uniqueMessages.add(message);
}
}
return uniqueMessages;
}
return dbLocalMessages;
}
Future<List<LocalChatMessage>> _fetchAndCacheMessages({
int offset = 0,
int take = 20,
}) async {
developer.log('Fetching messages from API, offset $offset, take $take', name: 'MessagesNotifier');
if (_totalCount == null) {
final response = await _apiClient.get(
'/sphere/chat/$_roomId/messages',
queryParameters: {'offset': 0, 'take': 1},
); );
_currentPage = 0; _totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0');
_hasMore = messages.length == _pageSize; }
if (offset >= _totalCount!) {
return [];
}
final response = await _apiClient.get(
'/sphere/chat/$_roomId/messages',
queryParameters: {'offset': offset, 'take': take},
);
final List<dynamic> data = response.data;
_totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0');
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; return messages;
} catch (_) { }
Future<void> syncMessages() async {
developer.log('Starting message sync', name: 'MessagesNotifier');
ref.read(isSyncingProvider.notifier).state = true;
try {
final dbMessages = await _database.getMessagesForRoom(
_room.id,
offset: 0,
limit: 1,
);
final lastMessage =
dbMessages.isEmpty ? null : _database.companionToMessage(dbMessages.first);
if (lastMessage == null) {
developer.log('No local messages, fetching from network', name: 'MessagesNotifier');
final newMessages = await _fetchAndCacheMessages(offset: 0, take: _pageSize);
state = AsyncValue.data(newMessages);
return;
}
final resp = await _apiClient.post(
'/sphere/chat/${_room.id}/sync',
data: {
'last_sync_timestamp':
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch,
},
);
final response = MessageSyncResponse.fromJson(resp.data);
developer.log('Sync response: ${response.changes.length} changes', name: 'MessagesNotifier');
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, stackTrace) {
developer.log('Error syncing messages', name: 'MessagesNotifier', error: err, stackTrace: stackTrace);
showErrorAlert(err);
} finally {
developer.log('Finished message sync', name: 'MessagesNotifier');
ref.read(isSyncingProvider.notifier).state = false;
}
}
Future<List<LocalChatMessage>> listMessages({
int offset = 0,
int take = 20,
bool synced = false,
}) async {
try {
if (offset == 0 && !synced) {
_fetchAndCacheMessages(offset: 0, take: take).catchError((_) {
return <LocalChatMessage>[];
});
}
final localMessages = await _getCachedMessages(
offset: offset,
take: take,
);
if (localMessages.isNotEmpty) {
return localMessages;
}
return await _fetchAndCacheMessages(offset: offset, take: take);
} catch (e) {
final localMessages = await _getCachedMessages(
offset: offset,
take: take,
);
if (localMessages.isNotEmpty) {
return localMessages;
}
rethrow; rethrow;
} }
} }
Future<List<LocalChatMessage>> loadInitial() async {
developer.log('Loading initial messages', name: 'MessagesNotifier');
syncMessages();
final messages = await _getCachedMessages(offset: 0, take: _pageSize);
_currentPage = 0;
_hasMore = messages.length == _pageSize;
return messages;
}
Future<void> loadMore() async { Future<void> loadMore() async {
if (!_hasMore || state is AsyncLoading) return; if (!_hasMore || state is AsyncLoading) return;
developer.log('Loading more messages', name: 'MessagesNotifier');
try { try {
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
_currentPage++; _currentPage++;
final repository = await ref.read( final newMessages = await listMessages(
messageRepositoryProvider(_roomId).future,
);
final newMessages = await repository.listMessages(
offset: _currentPage * _pageSize, offset: _currentPage * _pageSize,
take: _pageSize, take: _pageSize,
); );
@@ -99,7 +302,8 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
state = AsyncValue.data([...currentMessages, ...newMessages]); state = AsyncValue.data([...currentMessages, ...newMessages]);
} catch (err) { } catch (err, stackTrace) {
developer.log('Error loading more messages', name: 'MessagesNotifier', error: err, stackTrace: stackTrace);
showErrorAlert(err); showErrorAlert(err);
_currentPage--; _currentPage--;
} }
@@ -113,77 +317,196 @@ class MessagesNotifier extends _$MessagesNotifier {
SnChatMessage? replyingTo, SnChatMessage? replyingTo,
Function(String, Map<int, double>)? onProgress, Function(String, Map<int, double>)? onProgress,
}) async { }) async {
try { final nonce = const Uuid().v4();
final repository = await ref.read( developer.log('Sending message with nonce $nonce', name: 'MessagesNotifier');
messageRepositoryProvider(_roomId).future,
);
final baseUrl = ref.read(serverUrlProvider); final baseUrl = ref.read(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider)); final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Access token is null'); if (token == null) throw ArgumentError('Access token is null');
final currentMessages = state.value ?? []; final mockMessage = SnChatMessage(
await repository.sendMessage( id: 'pending_$nonce',
token, chatRoomId: _roomId,
baseUrl, senderId: _identity.id,
_roomId, content: content,
content, createdAt: DateTime.now(),
const Uuid().v4(), updatedAt: DateTime.now(),
attachments: attachments, nonce: nonce,
editingTo: editingTo, sender: _identity,
forwardingTo: forwardingTo,
replyingTo: replyingTo,
onPending: (pending) {
state = AsyncValue.data([pending, ...currentMessages]);
},
onProgress: onProgress,
); );
// Refresh messages final localMessage = LocalChatMessage.fromRemoteMessage(
final messages = await repository.listMessages( mockMessage,
offset: 0, MessageStatus.pending,
take: _pageSize,
); );
state = AsyncValue.data(messages);
} catch (err) { _pendingMessages[localMessage.id] = localMessage;
showErrorAlert(err); _fileUploadProgress[localMessage.id] = {};
await _database.saveMessage(_database.messageToCompanion(localMessage));
final currentMessages = state.value ?? [];
state = AsyncValue.data([localMessage, ...currentMessages]);
try {
var cloudAttachments = List.empty(growable: true);
for (var idx = 0; idx < attachments.length; idx++) {
final cloudFile = await putMediaToCloud(
fileData: attachments[idx],
atk: token,
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);
}
final response = await _apiClient.request(
editingTo == null
? '/sphere/chat/$_roomId/messages'
: '/sphere/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': {},
'nonce': nonce,
},
options: Options(method: editingTo == null ? 'POST' : 'PATCH'),
);
final remoteMessage = SnChatMessage.fromJson(response.data);
final updatedMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
);
_pendingMessages.remove(localMessage.id);
await _database.deleteMessage(localMessage.id);
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
final newMessages = (state.value ?? []).map((m) {
if (m.id == localMessage.id) {
return updatedMessage;
}
return m;
}).toList();
state = AsyncValue.data(newMessages);
developer.log('Message with nonce $nonce sent successfully', name: 'MessagesNotifier');
} catch (e, stackTrace) {
developer.log('Failed to send message with nonce $nonce', name: 'MessagesNotifier', error: e, stackTrace: stackTrace);
localMessage.status = MessageStatus.failed;
_pendingMessages[localMessage.id] = localMessage;
await _database.updateMessageStatus(
localMessage.id,
MessageStatus.failed,
);
final newMessages = (state.value ?? []).map((m) {
if (m.id == localMessage.id) {
return m..status = MessageStatus.failed;
}
return m;
}).toList();
state = AsyncValue.data(newMessages);
showErrorAlert(e);
} }
} }
Future<void> retryMessage(String pendingMessageId) async { Future<void> retryMessage(String pendingMessageId) async {
try { developer.log('Retrying message $pendingMessageId', name: 'MessagesNotifier');
final repository = await ref.read( final message = await fetchMessageById(pendingMessageId);
messageRepositoryProvider(_roomId).future, if (message == null) {
); throw Exception('Message not found');
final updatedMessage = await repository.retryMessage(pendingMessageId);
// Update the message in the list
final currentMessages = state.value ?? [];
final index = currentMessages.indexWhere((m) => m.id == pendingMessageId);
if (index >= 0) {
final newList = [...currentMessages];
newList[index] = updatedMessage;
state = AsyncValue.data(newList);
} }
} catch (err) {
showErrorAlert(err); message.status = MessageStatus.pending;
_pendingMessages[pendingMessageId] = message;
await _database.updateMessageStatus(
pendingMessageId,
MessageStatus.pending,
);
try {
var remoteMessage = message.toRemoteMessage();
final response = await _apiClient.post(
'/sphere/chat/${message.roomId}/messages',
data: {
'content': remoteMessage.content,
'attachments_id': remoteMessage.attachments,
'meta': remoteMessage.meta,
'nonce': message.nonce,
},
);
remoteMessage = SnChatMessage.fromJson(response.data);
final updatedMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
);
_pendingMessages.remove(pendingMessageId);
await _database.deleteMessage(pendingMessageId);
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
final newMessages = (state.value ?? []).map((m) {
if (m.id == pendingMessageId) {
return updatedMessage;
}
return m;
}).toList();
state = AsyncValue.data(newMessages);
} catch (e, stackTrace) {
developer.log('Failed to retry message $pendingMessageId', name: 'MessagesNotifier', error: e, stackTrace: stackTrace);
message.status = MessageStatus.failed;
_pendingMessages[pendingMessageId] = message;
await _database.updateMessageStatus(
pendingMessageId,
MessageStatus.failed,
);
final newMessages = (state.value ?? []).map((m) {
if (m.id == pendingMessageId) {
return m..status = MessageStatus.failed;
}
return m;
}).toList();
state = AsyncValue.data(newMessages);
showErrorAlert(e);
} }
} }
Future<void> receiveMessage(SnChatMessage remoteMessage) async { Future<void> receiveMessage(SnChatMessage remoteMessage) async {
try { if (remoteMessage.chatRoomId != _roomId) return;
final repository = await ref.read( developer.log('Received new message ${remoteMessage.id}', name: 'MessagesNotifier');
messageRepositoryProvider(_roomId).future,
final localMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
); );
// Skip if this message is not for this room if (remoteMessage.nonce != null) {
if (remoteMessage.chatRoomId != _roomId) return; _pendingMessages.removeWhere(
(_, pendingMsg) => pendingMsg.nonce == remoteMessage.nonce,
);
}
final localMessage = await repository.receiveMessage(remoteMessage); await _database.saveMessage(_database.messageToCompanion(localMessage));
// Add the new message to the state
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
// Check if the message already exists (by id or nonce)
final existingIndex = currentMessages.indexWhere( final existingIndex = currentMessages.indexWhere(
(m) => (m) =>
m.id == localMessage.id || m.id == localMessage.id ||
@@ -191,33 +514,24 @@ class MessagesNotifier extends _$MessagesNotifier {
); );
if (existingIndex >= 0) { if (existingIndex >= 0) {
// Replace existing message
final newList = [...currentMessages]; final newList = [...currentMessages];
newList[existingIndex] = localMessage; newList[existingIndex] = localMessage;
state = AsyncValue.data(newList); state = AsyncValue.data(newList);
} else { } else {
// Add new message at the beginning (newest first)
state = AsyncValue.data([localMessage, ...currentMessages]); state = AsyncValue.data([localMessage, ...currentMessages]);
} }
} catch (err) {
showErrorAlert(err);
}
} }
Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async { Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async {
try {
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
);
// Skip if this message is not for this room
if (remoteMessage.chatRoomId != _roomId) return; if (remoteMessage.chatRoomId != _roomId) return;
developer.log('Received message update ${remoteMessage.id}', name: 'MessagesNotifier');
final updatedMessage = await repository.receiveMessageUpdate( final updatedMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage, remoteMessage,
MessageStatus.sent,
); );
await _database.updateMessage(_database.messageToCompanion(updatedMessage));
// Update the message in the list
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
final index = currentMessages.indexWhere( final index = currentMessages.indexWhere(
(m) => m.id == updatedMessage.id, (m) => m.id == updatedMessage.id,
@@ -228,20 +542,13 @@ class MessagesNotifier extends _$MessagesNotifier {
newList[index] = updatedMessage; newList[index] = updatedMessage;
state = AsyncValue.data(newList); state = AsyncValue.data(newList);
} }
} catch (err) {
showErrorAlert(err);
}
} }
Future<void> receiveMessageDeletion(String messageId) async { Future<void> receiveMessageDeletion(String messageId) async {
try { developer.log('Received message deletion $messageId', name: 'MessagesNotifier');
final repository = await ref.read( _pendingMessages.remove(messageId);
messageRepositoryProvider(_roomId).future, await _database.deleteMessage(messageId);
);
await repository.receiveMessageDeletion(messageId);
// Remove the message from the list
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
final filteredMessages = final filteredMessages =
currentMessages.where((m) => m.id != messageId).toList(); currentMessages.where((m) => m.id != messageId).toList();
@@ -249,41 +556,43 @@ class MessagesNotifier extends _$MessagesNotifier {
if (filteredMessages.length != currentMessages.length) { if (filteredMessages.length != currentMessages.length) {
state = AsyncValue.data(filteredMessages); state = AsyncValue.data(filteredMessages);
} }
} catch (err) {
showErrorAlert(err);
}
} }
Future<void> deleteMessage(String messageId) async { Future<void> deleteMessage(String messageId) async {
developer.log('Deleting message $messageId', name: 'MessagesNotifier');
try { try {
final repository = await ref.read( await _apiClient.delete('/sphere/chat/$_roomId/messages/$messageId');
messageRepositoryProvider(_roomId).future, await receiveMessageDeletion(messageId);
); } catch (err, stackTrace) {
developer.log('Error deleting message $messageId', name: 'MessagesNotifier', error: err, stackTrace: stackTrace);
await repository.deleteMessage(messageId);
// Remove the message from the list
final currentMessages = state.value ?? [];
final filteredMessages =
currentMessages.where((m) => m.id != messageId).toList();
if (filteredMessages.length != currentMessages.length) {
state = AsyncValue.data(filteredMessages);
}
} catch (err) {
showErrorAlert(err); showErrorAlert(err);
} }
} }
Future<LocalChatMessage?> fetchMessageById(String messageId) async { Future<LocalChatMessage?> fetchMessageById(String messageId) async {
developer.log('Fetching message by id $messageId', name: 'MessagesNotifier');
try { try {
final repository = await ref.read( final localMessage = await (_database.select(_database.chatMessages)
messageRepositoryProvider(_roomId).future, ..where((tbl) => tbl.id.equals(messageId)))
.getSingleOrNull();
if (localMessage != null) {
return _database.companionToMessage(localMessage);
}
final response = await _apiClient.get(
'/sphere/chat/$_roomId/messages/$messageId',
); );
return await repository.getMessageById(messageId); final remoteMessage = SnChatMessage.fromJson(response.data);
} catch (err) { final message = LocalChatMessage.fromRemoteMessage(
showErrorAlert(err); remoteMessage,
return null; MessageStatus.sent,
);
await _database.saveMessage(_database.messageToCompanion(message));
return message;
} catch (e) {
if (e is DioException) return null;
rethrow;
} }
} }
} }
@@ -296,6 +605,7 @@ class ChatRoomScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final chatRoom = ref.watch(chatroomProvider(id)); final chatRoom = ref.watch(chatroomProvider(id));
final chatIdentity = ref.watch(chatroomIdentityProvider(id)); final chatIdentity = ref.watch(chatroomIdentityProvider(id));
final isSyncing = ref.watch(isSyncingProvider);
if (chatIdentity.isLoading || chatRoom.isLoading) { if (chatIdentity.isLoading || chatRoom.isLoading) {
return AppScaffold( return AppScaffold(
@@ -307,8 +617,7 @@ class ChatRoomScreen extends HookConsumerWidget {
return AppScaffold( return AppScaffold(
appBar: AppBar(leading: const PageBackButton()), appBar: AppBar(leading: const PageBackButton()),
body: Center( body: Center(
child: child: ConstrainedBox(
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280), constraints: const BoxConstraints(maxWidth: 280),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@@ -417,10 +726,8 @@ class ChatRoomScreen extends HookConsumerWidget {
if (typingStatuses.value.isNotEmpty) { if (typingStatuses.value.isNotEmpty) {
// Remove typing statuses older than 5 seconds // Remove typing statuses older than 5 seconds
final now = DateTime.now(); final now = DateTime.now();
typingStatuses.value = typingStatuses.value = typingStatuses.value.where((member) {
typingStatuses.value.where((member) { final lastTyped = member.lastTyped ??
final lastTyped =
member.lastTyped ??
DateTime.now().subtract(const Duration(milliseconds: 1350)); DateTime.now().subtract(const Duration(milliseconds: 1350));
return now.difference(lastTyped).inSeconds < 5; return now.difference(lastTyped).inSeconds < 5;
}).toList(); }).toList();
@@ -594,9 +901,7 @@ class ChatRoomScreen extends HookConsumerWidget {
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
toolbarHeight: compactHeader ? null : 64, toolbarHeight: compactHeader ? null : 64,
title: chatRoom.when( title: chatRoom.when(
data: data: (room) => compactHeader
(room) =>
compactHeader
? Row( ? Row(
spacing: 8, spacing: 8,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@@ -604,18 +909,11 @@ class ChatRoomScreen extends HookConsumerWidget {
SizedBox( SizedBox(
height: 26, height: 26,
width: 26, width: 26,
child: child: (room!.type == 1 && room.picture?.id == null)
(room!.type == 1 && room.picture?.id == null)
? SplitAvatarWidget( ? SplitAvatarWidget(
filesId: filesId: room.members!
room.members!
.map( .map(
(e) => (e) => e.account.profile.picture?.id,
e
.account
.profile
.picture
?.id,
) )
.toList(), .toList(),
) )
@@ -633,9 +931,7 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
Text( Text(
(room.type == 1 && room.name == null) (room.type == 1 && room.name == null)
? room.members! ? room.members!.map((e) => e.account.nick).join(', ')
.map((e) => e.account.nick)
.join(', ')
: room.name!, : room.name!,
).fontSize(19), ).fontSize(19),
], ],
@@ -648,18 +944,11 @@ class ChatRoomScreen extends HookConsumerWidget {
SizedBox( SizedBox(
height: 26, height: 26,
width: 26, width: 26,
child: child: (room!.type == 1 && room.picture?.id == null)
(room!.type == 1 && room.picture?.id == null)
? SplitAvatarWidget( ? SplitAvatarWidget(
filesId: filesId: room.members!
room.members!
.map( .map(
(e) => (e) => e.account.profile.picture?.id,
e
.account
.profile
.picture
?.id,
) )
.toList(), .toList(),
) )
@@ -677,16 +966,13 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
Text( Text(
(room.type == 1 && room.name == null) (room.type == 1 && room.name == null)
? room.members! ? room.members!.map((e) => e.account.nick).join(', ')
.map((e) => e.account.nick)
.join(', ')
: room.name!, : room.name!,
).fontSize(15), ).fontSize(15),
], ],
), ),
loading: () => const Text('Loading...'), loading: () => const Text('Loading...'),
error: error: (err, _) => ResponseErrorWidget(
(err, _) => ResponseErrorWidget(
error: err, error: err,
onRetry: () => messagesNotifier.loadInitial(), onRetry: () => messagesNotifier.loadInitial(),
), ),
@@ -701,6 +987,12 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
const Gap(8), const Gap(8),
], ],
bottom: isSyncing
? const PreferredSize(
preferredSize: Size.fromHeight(4.0),
child: LinearProgressIndicator(),
)
: null,
), ),
body: Stack( body: Stack(
children: [ children: [
@@ -708,16 +1000,13 @@ class ChatRoomScreen extends HookConsumerWidget {
children: [ children: [
Expanded( Expanded(
child: messages.when( child: messages.when(
data: data: (messageList) => messageList.isEmpty
(messageList) =>
messageList.isEmpty
? Center(child: Text('No messages yet'.tr())) ? Center(child: Text('No messages yet'.tr()))
: SuperListView.builder( : SuperListView.builder(
listController: listController, listController: listController,
padding: EdgeInsets.symmetric(vertical: 16), padding: EdgeInsets.symmetric(vertical: 16),
controller: scrollController, controller: scrollController,
reverse: reverse: true, // Show newest messages at the bottom
true, // Show newest messages at the bottom
itemCount: messageList.length, itemCount: messageList.length,
findChildIndexCallback: (key) { findChildIndexCallback: (key) {
final valueKey = key as ValueKey; final valueKey = key as ValueKey;
@@ -728,14 +1017,11 @@ class ChatRoomScreen extends HookConsumerWidget {
}, },
itemBuilder: (context, index) { itemBuilder: (context, index) {
final message = messageList[index]; final message = messageList[index];
final nextMessage = final nextMessage = index < messageList.length - 1
index < messageList.length - 1
? messageList[index + 1] ? messageList[index + 1]
: null; : null;
final isLastInGroup = final isLastInGroup = nextMessage == null ||
nextMessage == null || nextMessage.senderId != message.senderId ||
nextMessage.senderId !=
message.senderId ||
nextMessage.createdAt nextMessage.createdAt
.difference(message.createdAt) .difference(message.createdAt)
.inMinutes .inMinutes
@@ -744,11 +1030,10 @@ class ChatRoomScreen extends HookConsumerWidget {
return chatIdentity.when( return chatIdentity.when(
skipError: true, skipError: true,
data: data: (identity) => MessageItem(
(identity) => MessageItem( key: ValueKey(message.id),
message: message, message: message,
isCurrentUser: isCurrentUser: identity?.id == message.senderId,
identity?.id == message.senderId,
onAction: (action) { onAction: (action) {
switch (action) { switch (action) {
case MessageItemAction.delete: case MessageItemAction.delete:
@@ -759,17 +1044,11 @@ class ChatRoomScreen extends HookConsumerWidget {
messageEditingTo.value = messageEditingTo.value =
message.toRemoteMessage(); message.toRemoteMessage();
messageController.text = messageController.text =
messageEditingTo messageEditingTo.value?.content ?? '';
.value attachments.value = messageEditingTo
?.content ?? .value!.attachments
'';
attachments.value =
messageEditingTo
.value!
.attachments
.map( .map(
(e) => (e) => UniversalFile.fromAttachment(
UniversalFile.fromAttachment(
e, e,
), ),
) )
@@ -783,8 +1062,7 @@ class ChatRoomScreen extends HookConsumerWidget {
} }
}, },
onJump: (messageId) { onJump: (messageId) {
final messageIndex = messageList final messageIndex = messageList.indexWhere(
.indexWhere(
(m) => m.id == messageId, (m) => m.id == messageId,
); );
listController.jumpToItem( listController.jumpToItem(
@@ -794,13 +1072,10 @@ class ChatRoomScreen extends HookConsumerWidget {
alignment: 0.5, alignment: 0.5,
); );
}, },
progress: progress: attachmentProgress.value[message.id],
attachmentProgress.value[message
.id],
showAvatar: isLastInGroup, showAvatar: isLastInGroup,
), ),
loading: loading: () => MessageItem(
() => MessageItem(
message: message, message: message,
isCurrentUser: false, isCurrentUser: false,
onAction: null, onAction: null,
@@ -812,18 +1087,15 @@ class ChatRoomScreen extends HookConsumerWidget {
); );
}, },
), ),
loading: loading: () => const Center(child: CircularProgressIndicator()),
() => const Center(child: CircularProgressIndicator()), error: (error, _) => ResponseErrorWidget(
error:
(error, _) => ResponseErrorWidget(
error: error, error: error,
onRetry: () => messagesNotifier.loadInitial(), onRetry: () => messagesNotifier.loadInitial(),
), ),
), ),
), ),
chatRoom.when( chatRoom.when(
data: data: (room) => Column(
(room) => Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
AnimatedSwitcher( AnimatedSwitcher(
@@ -854,8 +1126,7 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
); );
}, },
child: child: typingStatuses.value.isNotEmpty
typingStatuses.value.isNotEmpty
? Container( ? Container(
key: const ValueKey('typing-indicator'), key: const ValueKey('typing-indicator'),
width: double.infinity, width: double.infinity,
@@ -878,14 +1149,12 @@ class ChatRoomScreen extends HookConsumerWidget {
typingStatuses.value typingStatuses.value
.map( .map(
(x) => (x) =>
x.nick ?? x.nick ?? x.account.nick,
x.account.nick,
) )
.join(', '), .join(', '),
], ],
), ),
style: style: Theme.of(
Theme.of(
context, context,
).textTheme.bodySmall, ).textTheme.bodySmall,
), ),
@@ -1154,14 +1423,11 @@ class _ChatInput extends HookConsumerWidget {
// Insert placeholder at current cursor position // Insert placeholder at current cursor position
final text = messageController.text; final text = messageController.text;
final selection = messageController.selection; final selection = messageController.selection;
final start = final start = selection.start >= 0
selection.start >= 0
? selection.start ? selection.start
: text.length; : text.length;
final end = final end =
selection.end >= 0 selection.end >= 0 ? selection.end : text.length;
? selection.end
: text.length;
final newText = text.replaceRange( final newText = text.replaceRange(
start, start,
end, end,
@@ -1179,8 +1445,7 @@ class _ChatInput extends HookConsumerWidget {
), ),
PopupMenuButton( PopupMenuButton(
icon: const Icon(Symbols.photo_library), icon: const Icon(Symbols.photo_library),
itemBuilder: itemBuilder: (context) => [
(context) => [
PopupMenuItem( PopupMenuItem(
onTap: () => onPickFile(true), onTap: () => onPickFile(true),
child: Row( child: Row(
@@ -1251,8 +1516,8 @@ class _ChatInput extends HookConsumerWidget {
), ),
), ),
maxLines: null, maxLines: null,
onTapOutside: onTapOutside: (_) =>
(_) => FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
), ),
), ),
), ),

View File

@@ -6,7 +6,7 @@ part of 'room.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$messagesNotifierHash() => r'afc4d43f4948ec571118cef0321838a6cefc89c0'; String _$messagesNotifierHash() => r'3b10c3101404f6528c7a83baa0d39cba1a30f579';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

View File

@@ -7,7 +7,7 @@ part of 'notification.dart';
// ************************************************************************** // **************************************************************************
String _$notificationUnreadCountNotifierHash() => String _$notificationUnreadCountNotifierHash() =>
r'd199abf0d16944587e747798399a267a790341f3'; r'0763b66bd64e5a9b7c317887e109ab367515dfa4';
/// See also [NotificationUnreadCountNotifier]. /// See also [NotificationUnreadCountNotifier].
@ProviderFor(NotificationUnreadCountNotifier) @ProviderFor(NotificationUnreadCountNotifier)