✨ Search and jump to message
This commit is contained in:
852
lib/pods/messages_notifier.dart
Normal file
852
lib/pods/messages_notifier.dart
Normal file
@@ -0,0 +1,852 @@
|
||||
import "dart:async";
|
||||
import "dart:developer" as developer;
|
||||
import "package:dio/dio.dart";
|
||||
import "package:drift/drift.dart" show Variable;
|
||||
import "package:easy_localization/easy_localization.dart";
|
||||
import "package:flutter/material.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/pods/config.dart";
|
||||
import "package:island/pods/database.dart";
|
||||
import "package:island/pods/network.dart";
|
||||
import "package:island/services/file.dart";
|
||||
import "package:island/widgets/alert.dart";
|
||||
import "package:riverpod_annotation/riverpod_annotation.dart";
|
||||
import "package:uuid/uuid.dart";
|
||||
import "package:island/screens/chat/chat.dart";
|
||||
import "package:island/pods/room_providers.dart";
|
||||
|
||||
part 'messages_notifier.g.dart';
|
||||
|
||||
@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;
|
||||
String? _searchQuery;
|
||||
bool? _withLinks;
|
||||
bool? _withAttachments;
|
||||
|
||||
late final String _roomId;
|
||||
static const int _pageSize = 20;
|
||||
bool _hasMore = true;
|
||||
bool _isSyncing = false;
|
||||
bool _isJumping = false;
|
||||
|
||||
@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) {
|
||||
throw Exception('Room not found');
|
||||
}
|
||||
_room = room;
|
||||
|
||||
// Allow building even if identity is null for public rooms
|
||||
if (identity != null) {
|
||||
_identity = identity;
|
||||
}
|
||||
|
||||
developer.log(
|
||||
'MessagesNotifier built for room $roomId',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
|
||||
// Only setup sync and lifecycle listeners if user is a member
|
||||
if (identity != null) {
|
||||
ref.listen(appLifecycleStateProvider, (_, next) {
|
||||
if (next.hasValue && next.value == AppLifecycleState.resumed) {
|
||||
developer.log(
|
||||
'App resumed, syncing messages',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
syncMessages();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadInitial();
|
||||
return [];
|
||||
}
|
||||
|
||||
List<LocalChatMessage> _sortMessages(List<LocalChatMessage> messages) {
|
||||
messages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return messages;
|
||||
}
|
||||
|
||||
Future<List<LocalChatMessage>> _getCachedMessages({
|
||||
int offset = 0,
|
||||
int take = 20,
|
||||
}) async {
|
||||
developer.log(
|
||||
'Getting cached messages from offset $offset, take $take',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
final List<LocalChatMessage> dbMessages;
|
||||
if (_searchQuery != null && _searchQuery!.isNotEmpty) {
|
||||
dbMessages = await _database.searchMessages(
|
||||
_roomId,
|
||||
_searchQuery ?? '',
|
||||
withAttachments: _withAttachments,
|
||||
);
|
||||
} else {
|
||||
final chatMessagesFromDb = await _database.getMessagesForRoom(
|
||||
_roomId,
|
||||
offset: offset,
|
||||
limit: take,
|
||||
);
|
||||
dbMessages =
|
||||
chatMessagesFromDb.map(_database.companionToMessage).toList();
|
||||
}
|
||||
|
||||
List<LocalChatMessage> filteredMessages = dbMessages;
|
||||
|
||||
if (_withLinks == true) {
|
||||
filteredMessages =
|
||||
filteredMessages.where((msg) => _hasLink(msg)).toList();
|
||||
}
|
||||
|
||||
final dbLocalMessages = filteredMessages;
|
||||
|
||||
// Always ensure unique messages to prevent duplicate keys
|
||||
final uniqueMessages = <LocalChatMessage>[];
|
||||
final seenIds = <String>{};
|
||||
for (final message in dbLocalMessages) {
|
||||
if (seenIds.add(message.id)) {
|
||||
uniqueMessages.add(message);
|
||||
}
|
||||
}
|
||||
|
||||
if (offset == 0) {
|
||||
final pendingForRoom =
|
||||
_pendingMessages.values
|
||||
.where((msg) => msg.roomId == _roomId)
|
||||
.toList();
|
||||
|
||||
final allMessages = [...pendingForRoom, ...uniqueMessages];
|
||||
_sortMessages(allMessages); // Use the helper function
|
||||
|
||||
final finalUniqueMessages = <LocalChatMessage>[];
|
||||
final finalSeenIds = <String>{};
|
||||
for (final message in allMessages) {
|
||||
if (finalSeenIds.add(message.id)) {
|
||||
finalUniqueMessages.add(message);
|
||||
}
|
||||
}
|
||||
return finalUniqueMessages;
|
||||
}
|
||||
|
||||
return uniqueMessages;
|
||||
}
|
||||
|
||||
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},
|
||||
);
|
||||
_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;
|
||||
}
|
||||
|
||||
Future<void> syncMessages() async {
|
||||
if (_isSyncing) {
|
||||
developer.log(
|
||||
'Sync already in progress, skipping.',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_isSyncing = true;
|
||||
|
||||
developer.log('Starting message sync', name: 'MessagesNotifier');
|
||||
Future.microtask(() => 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.messages.length} changes',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
for (final message in response.messages) {
|
||||
switch (message.type) {
|
||||
case "messages.update":
|
||||
case "messages.update.links":
|
||||
await receiveMessageUpdate(message);
|
||||
break;
|
||||
case "messages.delete":
|
||||
await receiveMessageDeletion(message.id.toString());
|
||||
break;
|
||||
}
|
||||
// Still need receive the message to show the history actions
|
||||
await receiveMessage(message);
|
||||
}
|
||||
} catch (err, stackTrace) {
|
||||
developer.log(
|
||||
'Error syncing messages',
|
||||
name: 'MessagesNotifier',
|
||||
error: err,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
developer.log('Finished message sync', name: 'MessagesNotifier');
|
||||
Future.microtask(
|
||||
() => ref.read(isSyncingProvider.notifier).state = false,
|
||||
);
|
||||
_isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<LocalChatMessage>> listMessages({
|
||||
int offset = 0,
|
||||
int take = 20,
|
||||
bool synced = false,
|
||||
}) async {
|
||||
try {
|
||||
if (offset == 0 &&
|
||||
!synced &&
|
||||
(_searchQuery == null || _searchQuery!.isEmpty)) {
|
||||
_fetchAndCacheMessages(offset: 0, take: take).catchError((_) {
|
||||
return <LocalChatMessage>[];
|
||||
});
|
||||
}
|
||||
|
||||
final localMessages = await _getCachedMessages(
|
||||
offset: offset,
|
||||
take: take,
|
||||
);
|
||||
|
||||
if (localMessages.isNotEmpty) {
|
||||
return localMessages;
|
||||
}
|
||||
|
||||
if (_searchQuery == null || _searchQuery!.isEmpty) {
|
||||
return await _fetchAndCacheMessages(offset: offset, take: take);
|
||||
} else {
|
||||
return []; // If searching, and no local messages, don't fetch from network
|
||||
}
|
||||
} catch (e) {
|
||||
final localMessages = await _getCachedMessages(
|
||||
offset: offset,
|
||||
take: take,
|
||||
);
|
||||
|
||||
if (localMessages.isNotEmpty) {
|
||||
return localMessages;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadInitial() async {
|
||||
developer.log('Loading initial messages', name: 'MessagesNotifier');
|
||||
if (_searchQuery == null || _searchQuery!.isEmpty) {
|
||||
syncMessages();
|
||||
}
|
||||
|
||||
final messages = await _getCachedMessages(offset: 0, take: _pageSize);
|
||||
|
||||
_hasMore = messages.length == _pageSize;
|
||||
|
||||
state = AsyncValue.data(messages);
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (!_hasMore || state is AsyncLoading) return;
|
||||
developer.log('Loading more messages', name: 'MessagesNotifier');
|
||||
|
||||
try {
|
||||
final currentMessages = state.value ?? [];
|
||||
final offset = currentMessages.length;
|
||||
|
||||
final newMessages = await listMessages(offset: offset, take: _pageSize);
|
||||
|
||||
if (newMessages.isEmpty || newMessages.length < _pageSize) {
|
||||
_hasMore = false;
|
||||
}
|
||||
|
||||
state = AsyncValue.data(
|
||||
_sortMessages([...currentMessages, ...newMessages]),
|
||||
);
|
||||
} catch (err, stackTrace) {
|
||||
developer.log(
|
||||
'Error loading more messages',
|
||||
name: 'MessagesNotifier',
|
||||
error: err,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendMessage(
|
||||
String content,
|
||||
List<UniversalFile> attachments, {
|
||||
SnChatMessage? editingTo,
|
||||
SnChatMessage? forwardingTo,
|
||||
SnChatMessage? replyingTo,
|
||||
Function(String, Map<int, double>)? onProgress,
|
||||
}) async {
|
||||
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 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,
|
||||
);
|
||||
|
||||
_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 putFileToCloud(
|
||||
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 currentMessages = state.value ?? [];
|
||||
if (editingTo != null) {
|
||||
final newMessages =
|
||||
currentMessages
|
||||
.where((m) => m.id != localMessage.id) // remove pending message
|
||||
.map(
|
||||
(m) => m.id == editingTo.id ? updatedMessage : m,
|
||||
) // update original message
|
||||
.toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
} else {
|
||||
final newMessages =
|
||||
currentMessages.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 {
|
||||
developer.log(
|
||||
'Retrying message $pendingMessageId',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
final message = await fetchMessageById(pendingMessageId);
|
||||
if (message == null) {
|
||||
throw Exception('Message not found');
|
||||
}
|
||||
|
||||
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(_sortMessages(newMessages));
|
||||
showErrorAlert(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> receiveMessage(SnChatMessage remoteMessage) async {
|
||||
if (remoteMessage.chatRoomId != _roomId) return;
|
||||
developer.log(
|
||||
'Received new message ${remoteMessage.id}',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
|
||||
final localMessage = LocalChatMessage.fromRemoteMessage(
|
||||
remoteMessage,
|
||||
MessageStatus.sent,
|
||||
);
|
||||
|
||||
if (remoteMessage.nonce != null) {
|
||||
_pendingMessages.removeWhere(
|
||||
(_, pendingMsg) => pendingMsg.nonce == remoteMessage.nonce,
|
||||
);
|
||||
}
|
||||
|
||||
await _database.saveMessage(_database.messageToCompanion(localMessage));
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final existingIndex = currentMessages.indexWhere(
|
||||
(m) =>
|
||||
m.id == localMessage.id ||
|
||||
(localMessage.nonce != null && m.nonce == localMessage.nonce),
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
final newList = [...currentMessages];
|
||||
newList[existingIndex] = localMessage;
|
||||
state = AsyncValue.data(_sortMessages(newList));
|
||||
} else {
|
||||
state = AsyncValue.data(
|
||||
_sortMessages([localMessage, ...currentMessages]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async {
|
||||
if (remoteMessage.chatRoomId != _roomId) return;
|
||||
developer.log(
|
||||
'Received message update ${remoteMessage.id}',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
|
||||
final updatedMessage = LocalChatMessage.fromRemoteMessage(
|
||||
remoteMessage,
|
||||
MessageStatus.sent,
|
||||
);
|
||||
await _database.updateMessage(_database.messageToCompanion(updatedMessage));
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final index = currentMessages.indexWhere((m) => m.id == updatedMessage.id);
|
||||
|
||||
if (index >= 0) {
|
||||
final newList = [...currentMessages];
|
||||
newList[index] = updatedMessage;
|
||||
state = AsyncValue.data(_sortMessages(newList));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> receiveMessageDeletion(String messageId) async {
|
||||
developer.log(
|
||||
'Received message deletion $messageId',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
_pendingMessages.remove(messageId);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final messageIndex = currentMessages.indexWhere((m) => m.id == messageId);
|
||||
|
||||
LocalChatMessage? messageToUpdate;
|
||||
if (messageIndex != -1) {
|
||||
messageToUpdate = currentMessages[messageIndex];
|
||||
} else {
|
||||
messageToUpdate = await fetchMessageById(messageId);
|
||||
}
|
||||
|
||||
if (messageToUpdate == null) return;
|
||||
|
||||
final remote = messageToUpdate.toRemoteMessage();
|
||||
final updatedRemote = remote.copyWith(
|
||||
content: 'This message was deleted',
|
||||
deletedAt: DateTime.now(),
|
||||
attachments: [],
|
||||
);
|
||||
|
||||
final deletedMessage = LocalChatMessage.fromRemoteMessage(
|
||||
updatedRemote,
|
||||
messageToUpdate.status,
|
||||
);
|
||||
|
||||
await _database.saveMessage(_database.messageToCompanion(deletedMessage));
|
||||
|
||||
if (messageIndex != -1) {
|
||||
final newList = [...currentMessages];
|
||||
newList[messageIndex] = deletedMessage;
|
||||
state = AsyncValue.data(newList);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteMessage(String messageId) async {
|
||||
developer.log('Deleting message $messageId', name: 'MessagesNotifier');
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
void searchMessages(String query, {bool? withLinks, bool? withAttachments}) {
|
||||
_searchQuery = query.trim();
|
||||
_withLinks = withLinks;
|
||||
_withAttachments = withAttachments;
|
||||
loadInitial();
|
||||
}
|
||||
|
||||
void clearSearch() {
|
||||
_searchQuery = null;
|
||||
_withLinks = null;
|
||||
_withAttachments = null;
|
||||
loadInitial();
|
||||
}
|
||||
|
||||
Future<LocalChatMessage?> fetchMessageById(String messageId) async {
|
||||
developer.log(
|
||||
'Fetching message by id $messageId',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
try {
|
||||
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',
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> jumpToMessage(String messageId) async {
|
||||
developer.log(
|
||||
'Starting jump to message $messageId',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
if (_isJumping) {
|
||||
developer.log(
|
||||
'Jump already in progress, skipping',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
_isJumping = true;
|
||||
|
||||
try {
|
||||
developer.log('Fetching message $messageId', name: 'MessagesNotifier');
|
||||
final message = await fetchMessageById(messageId);
|
||||
if (message == null) {
|
||||
developer.log('Message $messageId not found', name: 'MessagesNotifier');
|
||||
showSnackBar('messageNotFound'.tr());
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Check if message is already in current state to avoid duplicate loading
|
||||
final currentMessages = state.value ?? [];
|
||||
final existingIndex = currentMessages.indexWhere(
|
||||
(m) => m.id == messageId,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
developer.log(
|
||||
'Message $messageId already in current state at index $existingIndex, jumping directly',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
return existingIndex;
|
||||
}
|
||||
|
||||
developer.log(
|
||||
'Message $messageId not in current state, loading messages around it',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
|
||||
// Count messages newer than this one
|
||||
final query = _database.customSelect(
|
||||
'SELECT COUNT(*) as count FROM chat_messages WHERE room_id = ? AND created_at > ?',
|
||||
variables: [
|
||||
Variable.withString(_roomId),
|
||||
Variable.withDateTime(message.createdAt),
|
||||
],
|
||||
readsFrom: {_database.chatMessages},
|
||||
);
|
||||
final result = await query.getSingle();
|
||||
final newerCount = result.read<int>('count');
|
||||
|
||||
// Load messages around this position
|
||||
final offset =
|
||||
(newerCount - _pageSize ~/ 2).clamp(0, double.infinity).toInt();
|
||||
developer.log(
|
||||
'Loading messages with offset $offset, take $_pageSize',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
final loadedMessages = await _getCachedMessages(
|
||||
offset: offset,
|
||||
take: _pageSize,
|
||||
);
|
||||
|
||||
// Check if loaded messages are already in current state
|
||||
final currentIds = currentMessages.map((m) => m.id).toSet();
|
||||
final newMessages =
|
||||
loadedMessages.where((m) => !currentIds.contains(m.id)).toList();
|
||||
developer.log(
|
||||
'Loaded ${loadedMessages.length} messages, ${newMessages.length} are new',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
|
||||
if (newMessages.isNotEmpty) {
|
||||
// Merge with current messages
|
||||
final allMessages = [...currentMessages, ...newMessages];
|
||||
final uniqueMessages = <LocalChatMessage>[];
|
||||
final seenIds = <String>{};
|
||||
for (final message in allMessages) {
|
||||
if (seenIds.add(message.id)) {
|
||||
uniqueMessages.add(message);
|
||||
}
|
||||
}
|
||||
_sortMessages(uniqueMessages);
|
||||
state = AsyncValue.data(uniqueMessages);
|
||||
developer.log(
|
||||
'Updated state with ${uniqueMessages.length} total messages',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
}
|
||||
|
||||
final finalIndex = (state.value ?? []).indexWhere(
|
||||
(m) => m.id == messageId,
|
||||
);
|
||||
developer.log(
|
||||
'Final index for message $messageId is $finalIndex',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
return finalIndex;
|
||||
} finally {
|
||||
_isJumping = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasLink(LocalChatMessage message) {
|
||||
final content = message.toRemoteMessage().content;
|
||||
if (content == null) return false;
|
||||
final urlRegex = RegExp(r'https?://[^\s/$.?#].[^\s]*');
|
||||
return urlRegex.hasMatch(content);
|
||||
}
|
||||
}
|
179
lib/pods/messages_notifier.g.dart
Normal file
179
lib/pods/messages_notifier.g.dart
Normal file
@@ -0,0 +1,179 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'messages_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$messagesNotifierHash() => r'37bab723531a5c5248471ef810b74cf57b3dc237';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$MessagesNotifier
|
||||
extends BuildlessAutoDisposeAsyncNotifier<List<LocalChatMessage>> {
|
||||
late final String roomId;
|
||||
|
||||
FutureOr<List<LocalChatMessage>> build(String roomId);
|
||||
}
|
||||
|
||||
/// See also [MessagesNotifier].
|
||||
@ProviderFor(MessagesNotifier)
|
||||
const messagesNotifierProvider = MessagesNotifierFamily();
|
||||
|
||||
/// See also [MessagesNotifier].
|
||||
class MessagesNotifierFamily
|
||||
extends Family<AsyncValue<List<LocalChatMessage>>> {
|
||||
/// See also [MessagesNotifier].
|
||||
const MessagesNotifierFamily();
|
||||
|
||||
/// See also [MessagesNotifier].
|
||||
MessagesNotifierProvider call(String roomId) {
|
||||
return MessagesNotifierProvider(roomId);
|
||||
}
|
||||
|
||||
@override
|
||||
MessagesNotifierProvider getProviderOverride(
|
||||
covariant MessagesNotifierProvider provider,
|
||||
) {
|
||||
return call(provider.roomId);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'messagesNotifierProvider';
|
||||
}
|
||||
|
||||
/// See also [MessagesNotifier].
|
||||
class MessagesNotifierProvider
|
||||
extends
|
||||
AutoDisposeAsyncNotifierProviderImpl<
|
||||
MessagesNotifier,
|
||||
List<LocalChatMessage>
|
||||
> {
|
||||
/// See also [MessagesNotifier].
|
||||
MessagesNotifierProvider(String roomId)
|
||||
: this._internal(
|
||||
() => MessagesNotifier()..roomId = roomId,
|
||||
from: messagesNotifierProvider,
|
||||
name: r'messagesNotifierProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$messagesNotifierHash,
|
||||
dependencies: MessagesNotifierFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
MessagesNotifierFamily._allTransitiveDependencies,
|
||||
roomId: roomId,
|
||||
);
|
||||
|
||||
MessagesNotifierProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.roomId,
|
||||
}) : super.internal();
|
||||
|
||||
final String roomId;
|
||||
|
||||
@override
|
||||
FutureOr<List<LocalChatMessage>> runNotifierBuild(
|
||||
covariant MessagesNotifier notifier,
|
||||
) {
|
||||
return notifier.build(roomId);
|
||||
}
|
||||
|
||||
@override
|
||||
Override overrideWith(MessagesNotifier Function() create) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: MessagesNotifierProvider._internal(
|
||||
() => create()..roomId = roomId,
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
roomId: roomId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeAsyncNotifierProviderElement<
|
||||
MessagesNotifier,
|
||||
List<LocalChatMessage>
|
||||
>
|
||||
createElement() {
|
||||
return _MessagesNotifierProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is MessagesNotifierProvider && other.roomId == roomId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, roomId.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin MessagesNotifierRef
|
||||
on AutoDisposeAsyncNotifierProviderRef<List<LocalChatMessage>> {
|
||||
/// The parameter `roomId` of this provider.
|
||||
String get roomId;
|
||||
}
|
||||
|
||||
class _MessagesNotifierProviderElement
|
||||
extends
|
||||
AutoDisposeAsyncNotifierProviderElement<
|
||||
MessagesNotifier,
|
||||
List<LocalChatMessage>
|
||||
>
|
||||
with MessagesNotifierRef {
|
||||
_MessagesNotifierProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get roomId => (origin as MessagesNotifierProvider).roomId;
|
||||
}
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
34
lib/pods/room_providers.dart
Normal file
34
lib/pods/room_providers.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import "dart:async";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
|
||||
final isSyncingProvider = StateProvider.autoDispose<bool>((ref) => false);
|
||||
|
||||
final flashingMessagesProvider = StateProvider<Set<String>>((ref) => {});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user