Compare commits
2 Commits
67d130dc34
...
6892afb974
Author | SHA1 | Date | |
---|---|---|---|
|
6892afb974 | ||
|
007b46b080 |
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -10,14 +12,15 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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_repository.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/database.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/services/file.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
@@ -39,17 +42,46 @@ import 'package:island/widgets/stickers/picker.dart';
|
||||
|
||||
part 'room.g.dart';
|
||||
|
||||
final messageRepositoryProvider =
|
||||
FutureProvider.family<MessageRepository, String>((ref, roomId) async {
|
||||
final room = await ref.watch(chatroomProvider(roomId).future);
|
||||
final identity = await ref.watch(chatroomIdentityProvider(roomId).future);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final database = ref.watch(databaseProvider);
|
||||
return MessageRepository(room!, identity!, apiClient, database);
|
||||
final isSyncingProvider = StateProvider.autoDispose<bool>((ref) => false);
|
||||
|
||||
final appLifecycleStateProvider = StreamProvider<AppLifecycleState>((ref) {
|
||||
final controller = StreamController<AppLifecycleState>();
|
||||
|
||||
final observer = _AppLifecycleObserver((state) {
|
||||
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
|
||||
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;
|
||||
int _currentPage = 0;
|
||||
static const int _pageSize = 20;
|
||||
@@ -58,38 +90,209 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
@override
|
||||
FutureOr<List<LocalChatMessage>> build(String roomId) async {
|
||||
_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();
|
||||
}
|
||||
|
||||
Future<List<LocalChatMessage>> loadInitial() async {
|
||||
try {
|
||||
final repository = await ref.read(
|
||||
messageRepositoryProvider(_roomId).future,
|
||||
Future<List<LocalChatMessage>> _getCachedMessages({
|
||||
int offset = 0,
|
||||
int take = 20,
|
||||
}) 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 messages = await repository.listMessages(
|
||||
offset: 0,
|
||||
take: _pageSize,
|
||||
synced: synced,
|
||||
final dbLocalMessages =
|
||||
dbMessages.map(_database.companionToMessage).toList();
|
||||
|
||||
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));
|
||||
|
||||
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;
|
||||
_hasMore = messages.length == _pageSize;
|
||||
_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;
|
||||
_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;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!_hasMore || state is AsyncLoading) return;
|
||||
developer.log('Loading more messages', name: 'MessagesNotifier');
|
||||
|
||||
try {
|
||||
final currentMessages = state.value ?? [];
|
||||
_currentPage++;
|
||||
final repository = await ref.read(
|
||||
messageRepositoryProvider(_roomId).future,
|
||||
);
|
||||
final newMessages = await repository.listMessages(
|
||||
final newMessages = await listMessages(
|
||||
offset: _currentPage * _pageSize,
|
||||
take: _pageSize,
|
||||
);
|
||||
@@ -99,7 +302,8 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
|
||||
state = AsyncValue.data([...currentMessages, ...newMessages]);
|
||||
} catch (err) {
|
||||
} catch (err, stackTrace) {
|
||||
developer.log('Error loading more messages', name: 'MessagesNotifier', error: err, stackTrace: stackTrace);
|
||||
showErrorAlert(err);
|
||||
_currentPage--;
|
||||
}
|
||||
@@ -113,77 +317,196 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
SnChatMessage? replyingTo,
|
||||
Function(String, Map<int, double>)? onProgress,
|
||||
}) async {
|
||||
try {
|
||||
final repository = await ref.read(
|
||||
messageRepositoryProvider(_roomId).future,
|
||||
);
|
||||
final nonce = const Uuid().v4();
|
||||
developer.log('Sending message with nonce $nonce', name: 'MessagesNotifier');
|
||||
final baseUrl = ref.read(serverUrlProvider);
|
||||
final token = await getToken(ref.watch(tokenProvider));
|
||||
if (token == null) throw ArgumentError('Access token is null');
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
await repository.sendMessage(
|
||||
token,
|
||||
baseUrl,
|
||||
_roomId,
|
||||
content,
|
||||
const Uuid().v4(),
|
||||
attachments: attachments,
|
||||
editingTo: editingTo,
|
||||
forwardingTo: forwardingTo,
|
||||
replyingTo: replyingTo,
|
||||
onPending: (pending) {
|
||||
state = AsyncValue.data([pending, ...currentMessages]);
|
||||
},
|
||||
onProgress: onProgress,
|
||||
final mockMessage = SnChatMessage(
|
||||
id: 'pending_$nonce',
|
||||
chatRoomId: _roomId,
|
||||
senderId: _identity.id,
|
||||
content: content,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
nonce: nonce,
|
||||
sender: _identity,
|
||||
);
|
||||
|
||||
// Refresh messages
|
||||
final messages = await repository.listMessages(
|
||||
offset: 0,
|
||||
take: _pageSize,
|
||||
final localMessage = LocalChatMessage.fromRemoteMessage(
|
||||
mockMessage,
|
||||
MessageStatus.pending,
|
||||
);
|
||||
state = AsyncValue.data(messages);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
|
||||
_pendingMessages[localMessage.id] = localMessage;
|
||||
_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 {
|
||||
try {
|
||||
final repository = await ref.read(
|
||||
messageRepositoryProvider(_roomId).future,
|
||||
);
|
||||
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);
|
||||
developer.log('Retrying message $pendingMessageId', name: 'MessagesNotifier');
|
||||
final message = await fetchMessageById(pendingMessageId);
|
||||
if (message == null) {
|
||||
throw Exception('Message not found');
|
||||
}
|
||||
} 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 {
|
||||
try {
|
||||
final repository = await ref.read(
|
||||
messageRepositoryProvider(_roomId).future,
|
||||
if (remoteMessage.chatRoomId != _roomId) return;
|
||||
developer.log('Received new message ${remoteMessage.id}', name: 'MessagesNotifier');
|
||||
|
||||
final localMessage = LocalChatMessage.fromRemoteMessage(
|
||||
remoteMessage,
|
||||
MessageStatus.sent,
|
||||
);
|
||||
|
||||
// Skip if this message is not for this room
|
||||
if (remoteMessage.chatRoomId != _roomId) return;
|
||||
if (remoteMessage.nonce != null) {
|
||||
_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 ?? [];
|
||||
|
||||
// Check if the message already exists (by id or nonce)
|
||||
final existingIndex = currentMessages.indexWhere(
|
||||
(m) =>
|
||||
m.id == localMessage.id ||
|
||||
@@ -191,33 +514,24 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Replace existing message
|
||||
final newList = [...currentMessages];
|
||||
newList[existingIndex] = localMessage;
|
||||
state = AsyncValue.data(newList);
|
||||
} else {
|
||||
// Add new message at the beginning (newest first)
|
||||
state = AsyncValue.data([localMessage, ...currentMessages]);
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
developer.log('Received message update ${remoteMessage.id}', name: 'MessagesNotifier');
|
||||
|
||||
final updatedMessage = await repository.receiveMessageUpdate(
|
||||
final updatedMessage = LocalChatMessage.fromRemoteMessage(
|
||||
remoteMessage,
|
||||
MessageStatus.sent,
|
||||
);
|
||||
await _database.updateMessage(_database.messageToCompanion(updatedMessage));
|
||||
|
||||
// Update the message in the list
|
||||
final currentMessages = state.value ?? [];
|
||||
final index = currentMessages.indexWhere(
|
||||
(m) => m.id == updatedMessage.id,
|
||||
@@ -228,20 +542,13 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
newList[index] = updatedMessage;
|
||||
state = AsyncValue.data(newList);
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> receiveMessageDeletion(String messageId) async {
|
||||
try {
|
||||
final repository = await ref.read(
|
||||
messageRepositoryProvider(_roomId).future,
|
||||
);
|
||||
developer.log('Received message deletion $messageId', name: 'MessagesNotifier');
|
||||
_pendingMessages.remove(messageId);
|
||||
await _database.deleteMessage(messageId);
|
||||
|
||||
await repository.receiveMessageDeletion(messageId);
|
||||
|
||||
// Remove the message from the list
|
||||
final currentMessages = state.value ?? [];
|
||||
final filteredMessages =
|
||||
currentMessages.where((m) => m.id != messageId).toList();
|
||||
@@ -249,41 +556,43 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
if (filteredMessages.length != currentMessages.length) {
|
||||
state = AsyncValue.data(filteredMessages);
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteMessage(String messageId) async {
|
||||
developer.log('Deleting message $messageId', name: 'MessagesNotifier');
|
||||
try {
|
||||
final repository = await ref.read(
|
||||
messageRepositoryProvider(_roomId).future,
|
||||
);
|
||||
|
||||
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) {
|
||||
await _apiClient.delete('/sphere/chat/$_roomId/messages/$messageId');
|
||||
await receiveMessageDeletion(messageId);
|
||||
} catch (err, stackTrace) {
|
||||
developer.log('Error deleting message $messageId', name: 'MessagesNotifier', error: err, stackTrace: stackTrace);
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<LocalChatMessage?> fetchMessageById(String messageId) async {
|
||||
developer.log('Fetching message by id $messageId', name: 'MessagesNotifier');
|
||||
try {
|
||||
final repository = await ref.read(
|
||||
messageRepositoryProvider(_roomId).future,
|
||||
final localMessage = await (_database.select(_database.chatMessages)
|
||||
..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);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
return null;
|
||||
final remoteMessage = SnChatMessage.fromJson(response.data);
|
||||
final message = LocalChatMessage.fromRemoteMessage(
|
||||
remoteMessage,
|
||||
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) {
|
||||
final chatRoom = ref.watch(chatroomProvider(id));
|
||||
final chatIdentity = ref.watch(chatroomIdentityProvider(id));
|
||||
final isSyncing = ref.watch(isSyncingProvider);
|
||||
|
||||
if (chatIdentity.isLoading || chatRoom.isLoading) {
|
||||
return AppScaffold(
|
||||
@@ -307,8 +617,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(leading: const PageBackButton()),
|
||||
body: Center(
|
||||
child:
|
||||
ConstrainedBox(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
@@ -417,10 +726,8 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
if (typingStatuses.value.isNotEmpty) {
|
||||
// Remove typing statuses older than 5 seconds
|
||||
final now = DateTime.now();
|
||||
typingStatuses.value =
|
||||
typingStatuses.value.where((member) {
|
||||
final lastTyped =
|
||||
member.lastTyped ??
|
||||
typingStatuses.value = typingStatuses.value.where((member) {
|
||||
final lastTyped = member.lastTyped ??
|
||||
DateTime.now().subtract(const Duration(milliseconds: 1350));
|
||||
return now.difference(lastTyped).inSeconds < 5;
|
||||
}).toList();
|
||||
@@ -594,9 +901,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
automaticallyImplyLeading: false,
|
||||
toolbarHeight: compactHeader ? null : 64,
|
||||
title: chatRoom.when(
|
||||
data:
|
||||
(room) =>
|
||||
compactHeader
|
||||
data: (room) => compactHeader
|
||||
? Row(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
@@ -604,18 +909,11 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
SizedBox(
|
||||
height: 26,
|
||||
width: 26,
|
||||
child:
|
||||
(room!.type == 1 && room.picture?.id == null)
|
||||
child: (room!.type == 1 && room.picture?.id == null)
|
||||
? SplitAvatarWidget(
|
||||
filesId:
|
||||
room.members!
|
||||
filesId: room.members!
|
||||
.map(
|
||||
(e) =>
|
||||
e
|
||||
.account
|
||||
.profile
|
||||
.picture
|
||||
?.id,
|
||||
(e) => e.account.profile.picture?.id,
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
@@ -633,9 +931,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
),
|
||||
Text(
|
||||
(room.type == 1 && room.name == null)
|
||||
? room.members!
|
||||
.map((e) => e.account.nick)
|
||||
.join(', ')
|
||||
? room.members!.map((e) => e.account.nick).join(', ')
|
||||
: room.name!,
|
||||
).fontSize(19),
|
||||
],
|
||||
@@ -648,18 +944,11 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
SizedBox(
|
||||
height: 26,
|
||||
width: 26,
|
||||
child:
|
||||
(room!.type == 1 && room.picture?.id == null)
|
||||
child: (room!.type == 1 && room.picture?.id == null)
|
||||
? SplitAvatarWidget(
|
||||
filesId:
|
||||
room.members!
|
||||
filesId: room.members!
|
||||
.map(
|
||||
(e) =>
|
||||
e
|
||||
.account
|
||||
.profile
|
||||
.picture
|
||||
?.id,
|
||||
(e) => e.account.profile.picture?.id,
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
@@ -677,16 +966,13 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
),
|
||||
Text(
|
||||
(room.type == 1 && room.name == null)
|
||||
? room.members!
|
||||
.map((e) => e.account.nick)
|
||||
.join(', ')
|
||||
? room.members!.map((e) => e.account.nick).join(', ')
|
||||
: room.name!,
|
||||
).fontSize(15),
|
||||
],
|
||||
),
|
||||
loading: () => const Text('Loading...'),
|
||||
error:
|
||||
(err, _) => ResponseErrorWidget(
|
||||
error: (err, _) => ResponseErrorWidget(
|
||||
error: err,
|
||||
onRetry: () => messagesNotifier.loadInitial(),
|
||||
),
|
||||
@@ -701,6 +987,12 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
bottom: isSyncing
|
||||
? const PreferredSize(
|
||||
preferredSize: Size.fromHeight(4.0),
|
||||
child: LinearProgressIndicator(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
@@ -708,16 +1000,13 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: messages.when(
|
||||
data:
|
||||
(messageList) =>
|
||||
messageList.isEmpty
|
||||
data: (messageList) => messageList.isEmpty
|
||||
? Center(child: Text('No messages yet'.tr()))
|
||||
: SuperListView.builder(
|
||||
listController: listController,
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
controller: scrollController,
|
||||
reverse:
|
||||
true, // Show newest messages at the bottom
|
||||
reverse: true, // Show newest messages at the bottom
|
||||
itemCount: messageList.length,
|
||||
findChildIndexCallback: (key) {
|
||||
final valueKey = key as ValueKey;
|
||||
@@ -728,14 +1017,11 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final message = messageList[index];
|
||||
final nextMessage =
|
||||
index < messageList.length - 1
|
||||
final nextMessage = index < messageList.length - 1
|
||||
? messageList[index + 1]
|
||||
: null;
|
||||
final isLastInGroup =
|
||||
nextMessage == null ||
|
||||
nextMessage.senderId !=
|
||||
message.senderId ||
|
||||
final isLastInGroup = nextMessage == null ||
|
||||
nextMessage.senderId != message.senderId ||
|
||||
nextMessage.createdAt
|
||||
.difference(message.createdAt)
|
||||
.inMinutes
|
||||
@@ -744,11 +1030,10 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
|
||||
return chatIdentity.when(
|
||||
skipError: true,
|
||||
data:
|
||||
(identity) => MessageItem(
|
||||
data: (identity) => MessageItem(
|
||||
key: ValueKey(message.id),
|
||||
message: message,
|
||||
isCurrentUser:
|
||||
identity?.id == message.senderId,
|
||||
isCurrentUser: identity?.id == message.senderId,
|
||||
onAction: (action) {
|
||||
switch (action) {
|
||||
case MessageItemAction.delete:
|
||||
@@ -759,17 +1044,11 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
messageEditingTo.value =
|
||||
message.toRemoteMessage();
|
||||
messageController.text =
|
||||
messageEditingTo
|
||||
.value
|
||||
?.content ??
|
||||
'';
|
||||
attachments.value =
|
||||
messageEditingTo
|
||||
.value!
|
||||
.attachments
|
||||
messageEditingTo.value?.content ?? '';
|
||||
attachments.value = messageEditingTo
|
||||
.value!.attachments
|
||||
.map(
|
||||
(e) =>
|
||||
UniversalFile.fromAttachment(
|
||||
(e) => UniversalFile.fromAttachment(
|
||||
e,
|
||||
),
|
||||
)
|
||||
@@ -783,8 +1062,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
}
|
||||
},
|
||||
onJump: (messageId) {
|
||||
final messageIndex = messageList
|
||||
.indexWhere(
|
||||
final messageIndex = messageList.indexWhere(
|
||||
(m) => m.id == messageId,
|
||||
);
|
||||
listController.jumpToItem(
|
||||
@@ -794,13 +1072,10 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
alignment: 0.5,
|
||||
);
|
||||
},
|
||||
progress:
|
||||
attachmentProgress.value[message
|
||||
.id],
|
||||
progress: attachmentProgress.value[message.id],
|
||||
showAvatar: isLastInGroup,
|
||||
),
|
||||
loading:
|
||||
() => MessageItem(
|
||||
loading: () => MessageItem(
|
||||
message: message,
|
||||
isCurrentUser: false,
|
||||
onAction: null,
|
||||
@@ -812,18 +1087,15 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
loading:
|
||||
() => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, _) => ResponseErrorWidget(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry: () => messagesNotifier.loadInitial(),
|
||||
),
|
||||
),
|
||||
),
|
||||
chatRoom.when(
|
||||
data:
|
||||
(room) => Column(
|
||||
data: (room) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedSwitcher(
|
||||
@@ -854,8 +1126,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
},
|
||||
child:
|
||||
typingStatuses.value.isNotEmpty
|
||||
child: typingStatuses.value.isNotEmpty
|
||||
? Container(
|
||||
key: const ValueKey('typing-indicator'),
|
||||
width: double.infinity,
|
||||
@@ -878,14 +1149,12 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
typingStatuses.value
|
||||
.map(
|
||||
(x) =>
|
||||
x.nick ??
|
||||
x.account.nick,
|
||||
x.nick ?? x.account.nick,
|
||||
)
|
||||
.join(', '),
|
||||
],
|
||||
),
|
||||
style:
|
||||
Theme.of(
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall,
|
||||
),
|
||||
@@ -1154,14 +1423,11 @@ class _ChatInput extends HookConsumerWidget {
|
||||
// Insert placeholder at current cursor position
|
||||
final text = messageController.text;
|
||||
final selection = messageController.selection;
|
||||
final start =
|
||||
selection.start >= 0
|
||||
final start = selection.start >= 0
|
||||
? selection.start
|
||||
: text.length;
|
||||
final end =
|
||||
selection.end >= 0
|
||||
? selection.end
|
||||
: text.length;
|
||||
selection.end >= 0 ? selection.end : text.length;
|
||||
final newText = text.replaceRange(
|
||||
start,
|
||||
end,
|
||||
@@ -1179,8 +1445,7 @@ class _ChatInput extends HookConsumerWidget {
|
||||
),
|
||||
PopupMenuButton(
|
||||
icon: const Icon(Symbols.photo_library),
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
onTap: () => onPickFile(true),
|
||||
child: Row(
|
||||
@@ -1251,8 +1516,8 @@ class _ChatInput extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@@ -6,7 +6,7 @@ part of 'room.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$messagesNotifierHash() => r'afc4d43f4948ec571118cef0321838a6cefc89c0';
|
||||
String _$messagesNotifierHash() => r'3b10c3101404f6528c7a83baa0d39cba1a30f579';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
@@ -7,7 +7,7 @@ part of 'notification.dart';
|
||||
// **************************************************************************
|
||||
|
||||
String _$notificationUnreadCountNotifierHash() =>
|
||||
r'd199abf0d16944587e747798399a267a790341f3';
|
||||
r'0763b66bd64e5a9b7c317887e109ab367515dfa4';
|
||||
|
||||
/// See also [NotificationUnreadCountNotifier].
|
||||
@ProviderFor(NotificationUnreadCountNotifier)
|
||||
|
Reference in New Issue
Block a user