🐛 Fix bugs in message db
This commit is contained in:
@@ -160,6 +160,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
String roomId,
|
String roomId,
|
||||||
String query, {
|
String query, {
|
||||||
bool? withAttachments,
|
bool? withAttachments,
|
||||||
|
Future<SnAccount?> Function(String accountId)? fetchAccount,
|
||||||
}) async {
|
}) async {
|
||||||
var selectStatement = select(chatMessages)
|
var selectStatement = select(chatMessages)
|
||||||
..where((m) => m.roomId.equals(roomId));
|
..where((m) => m.roomId.equals(roomId));
|
||||||
@@ -186,7 +187,9 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
..orderBy([(m) => OrderingTerm.desc(m.createdAt)]))
|
..orderBy([(m) => OrderingTerm.desc(m.createdAt)]))
|
||||||
.get();
|
.get();
|
||||||
final messageFutures =
|
final messageFutures =
|
||||||
messages.map((msg) => companionToMessage(msg)).toList();
|
messages
|
||||||
|
.map((msg) => companionToMessage(msg, fetchAccount: fetchAccount))
|
||||||
|
.toList();
|
||||||
return await Future.wait(messageFutures);
|
return await Future.wait(messageFutures);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,18 +218,19 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<LocalChatMessage> companionToMessage(ChatMessage dbMessage) async {
|
Future<LocalChatMessage> companionToMessage(
|
||||||
|
ChatMessage dbMessage, {
|
||||||
|
Future<SnAccount?> Function(String accountId)? fetchAccount,
|
||||||
|
}) async {
|
||||||
final data = jsonDecode(dbMessage.data);
|
final data = jsonDecode(dbMessage.data);
|
||||||
SnChatMember? sender;
|
SnChatMember? sender;
|
||||||
try {
|
try {
|
||||||
final senderRow =
|
final senderRow =
|
||||||
await (select(chatMembers)
|
await (select(chatMembers)
|
||||||
..where((m) => m.id.equals(dbMessage.senderId))).getSingle();
|
..where((m) => m.id.equals(dbMessage.senderId))).getSingle();
|
||||||
final senderAccount = SnAccount.fromJson(senderRow.account);
|
SnAccount senderAccount;
|
||||||
SnAccountStatus? senderStatus;
|
senderAccount = SnAccount.fromJson(senderRow.account);
|
||||||
if (senderRow.status != null) {
|
|
||||||
senderStatus = SnAccountStatus.fromJson(jsonDecode(senderRow.status!));
|
|
||||||
}
|
|
||||||
sender = SnChatMember(
|
sender = SnChatMember(
|
||||||
id: senderRow.id,
|
id: senderRow.id,
|
||||||
chatRoomId: senderRow.chatRoomId,
|
chatRoomId: senderRow.chatRoomId,
|
||||||
@@ -239,15 +243,57 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
breakUntil: senderRow.breakUntil,
|
breakUntil: senderRow.breakUntil,
|
||||||
timeoutUntil: senderRow.timeoutUntil,
|
timeoutUntil: senderRow.timeoutUntil,
|
||||||
isBot: senderRow.isBot,
|
isBot: senderRow.isBot,
|
||||||
status: senderStatus,
|
status: null,
|
||||||
lastTyped: senderRow.lastTyped,
|
lastTyped: senderRow.lastTyped,
|
||||||
createdAt: senderRow.createdAt,
|
createdAt: senderRow.createdAt,
|
||||||
updatedAt: senderRow.updatedAt,
|
updatedAt: senderRow.updatedAt,
|
||||||
deletedAt: senderRow.deletedAt,
|
deletedAt: senderRow.deletedAt,
|
||||||
chatRoom: null,
|
chatRoom: null,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (err) {
|
||||||
sender = null;
|
// Fallback to dummy sender with senderId as display name
|
||||||
|
sender = SnChatMember(
|
||||||
|
id: 'unknown',
|
||||||
|
chatRoomId: dbMessage.roomId,
|
||||||
|
accountId: dbMessage.senderId,
|
||||||
|
account: SnAccount(
|
||||||
|
id: 'unknown',
|
||||||
|
name: 'unknown',
|
||||||
|
nick: dbMessage.senderId, // Show the ID instead of Unknown
|
||||||
|
profile: SnAccountProfile(
|
||||||
|
picture: null,
|
||||||
|
id: 'unknown',
|
||||||
|
experience: 0,
|
||||||
|
level: 1,
|
||||||
|
levelingProgress: 0.0,
|
||||||
|
background: null,
|
||||||
|
verification: null,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
deletedAt: null,
|
||||||
|
),
|
||||||
|
language: '',
|
||||||
|
isSuperuser: false,
|
||||||
|
automatedId: null,
|
||||||
|
perkSubscription: null,
|
||||||
|
deletedAt: null,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
nick: dbMessage.senderId, // Show the senderId as fallback
|
||||||
|
role: 0,
|
||||||
|
notify: 0,
|
||||||
|
joinedAt: null,
|
||||||
|
breakUntil: null,
|
||||||
|
timeoutUntil: null,
|
||||||
|
isBot: false,
|
||||||
|
status: null,
|
||||||
|
lastTyped: null,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
deletedAt: null,
|
||||||
|
chatRoom: null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return LocalChatMessage(
|
return LocalChatMessage(
|
||||||
id: dbMessage.id,
|
id: dbMessage.id,
|
||||||
@@ -377,4 +423,10 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
return await (select(postDrafts)
|
return await (select(postDrafts)
|
||||||
..where((tbl) => tbl.id.equals(id))).getSingleOrNull();
|
..where((tbl) => tbl.id.equals(id))).getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> saveMember(SnChatMember member) async {
|
||||||
|
await into(
|
||||||
|
chatMembers,
|
||||||
|
).insert(companionFromMember(member), mode: InsertMode.insertOrReplace);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,10 +158,11 @@ class LocalChatMessage {
|
|||||||
});
|
});
|
||||||
|
|
||||||
SnChatMessage toRemoteMessage() {
|
SnChatMessage toRemoteMessage() {
|
||||||
final msgData = Map<String, dynamic>.from(data);
|
if (sender == null) {
|
||||||
if (sender != null) {
|
throw Exception('Cannot create remote message without sender');
|
||||||
msgData['sender'] = sender!.toJson();
|
|
||||||
}
|
}
|
||||||
|
final msgData = Map<String, dynamic>.from(data);
|
||||||
|
msgData['sender'] = sender!.toJson();
|
||||||
return SnChatMessage.fromJson(msgData);
|
return SnChatMessage.fromJson(msgData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,8 +171,20 @@ class LocalChatMessage {
|
|||||||
MessageStatus status, {
|
MessageStatus status, {
|
||||||
String? nonce,
|
String? nonce,
|
||||||
}) {
|
}) {
|
||||||
final msgData = Map<String, dynamic>.from(message.toJson())
|
final jsonData = message.toJson();
|
||||||
..remove('sender');
|
jsonData.remove('sender');
|
||||||
|
// Ensure proper defaults for collections to prevent type cast errors
|
||||||
|
if (jsonData['meta'] == null) jsonData['meta'] = <String, dynamic>{};
|
||||||
|
if (jsonData['members_mentioned'] == null) {
|
||||||
|
jsonData['members_mentioned'] = <String>[];
|
||||||
|
}
|
||||||
|
if (jsonData['attachments'] == null) {
|
||||||
|
jsonData['attachments'] = <Map<String, dynamic>>[];
|
||||||
|
}
|
||||||
|
if (jsonData['reactions'] == null) {
|
||||||
|
jsonData['reactions'] = <Map<String, dynamic>>[];
|
||||||
|
}
|
||||||
|
final msgData = Map<String, dynamic>.from(jsonData);
|
||||||
return LocalChatMessage(
|
return LocalChatMessage(
|
||||||
id: message.id,
|
id: message.id,
|
||||||
roomId: message.chatRoomId,
|
roomId: message.chatRoomId,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import "package:flutter/material.dart";
|
|||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:island/database/drift_db.dart";
|
import "package:island/database/drift_db.dart";
|
||||||
import "package:island/database/message.dart";
|
import "package:island/database/message.dart";
|
||||||
|
import "package:island/models/account.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/models/poll.dart";
|
import "package:island/models/poll.dart";
|
||||||
@@ -20,6 +21,7 @@ import "package:riverpod_annotation/riverpod_annotation.dart";
|
|||||||
import "package:uuid/uuid.dart";
|
import "package:uuid/uuid.dart";
|
||||||
import "package:island/screens/chat/chat.dart";
|
import "package:island/screens/chat/chat.dart";
|
||||||
import "package:island/pods/chat/chat_rooms.dart";
|
import "package:island/pods/chat/chat_rooms.dart";
|
||||||
|
import "package:island/screens/account/profile.dart";
|
||||||
|
|
||||||
part 'messages_notifier.g.dart';
|
part 'messages_notifier.g.dart';
|
||||||
|
|
||||||
@@ -45,6 +47,8 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
bool _isUpdatingState = false;
|
bool _isUpdatingState = false;
|
||||||
DateTime? _lastPauseTime;
|
DateTime? _lastPauseTime;
|
||||||
|
|
||||||
|
late final Future<SnAccount?> Function(String) _fetchAccount;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<List<LocalChatMessage>> build(String roomId) async {
|
FutureOr<List<LocalChatMessage>> build(String roomId) async {
|
||||||
_roomId = roomId;
|
_roomId = roomId;
|
||||||
@@ -53,6 +57,15 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
final room = await ref.watch(chatroomProvider(roomId).future);
|
final room = await ref.watch(chatroomProvider(roomId).future);
|
||||||
final identity = await ref.watch(chatroomIdentityProvider(roomId).future);
|
final identity = await ref.watch(chatroomIdentityProvider(roomId).future);
|
||||||
|
|
||||||
|
// Initialize fetch account method for corrupted data recovery
|
||||||
|
_fetchAccount = (String accountId) async {
|
||||||
|
try {
|
||||||
|
return await ref.watch(accountProvider(accountId).future);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (room == null) {
|
if (room == null) {
|
||||||
throw Exception('Room not found');
|
throw Exception('Room not found');
|
||||||
}
|
}
|
||||||
@@ -133,6 +146,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
_roomId,
|
_roomId,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
withAttachments: withAttachments,
|
withAttachments: withAttachments,
|
||||||
|
fetchAccount: _fetchAccount,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
final chatMessagesFromDb = await _database.getMessagesForRoom(
|
final chatMessagesFromDb = await _database.getMessagesForRoom(
|
||||||
@@ -142,7 +156,12 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
);
|
);
|
||||||
dbMessages = await Future.wait(
|
dbMessages = await Future.wait(
|
||||||
chatMessagesFromDb
|
chatMessagesFromDb
|
||||||
.map((msg) => _database.companionToMessage(msg))
|
.map(
|
||||||
|
(msg) => _database.companionToMessage(
|
||||||
|
msg,
|
||||||
|
fetchAccount: _fetchAccount,
|
||||||
|
),
|
||||||
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -207,7 +226,10 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
);
|
);
|
||||||
final dbMessages = await Future.wait(
|
final dbMessages = await Future.wait(
|
||||||
chatMessagesFromDb
|
chatMessagesFromDb
|
||||||
.map((msg) => _database.companionToMessage(msg))
|
.map(
|
||||||
|
(msg) =>
|
||||||
|
_database.companionToMessage(msg, fetchAccount: _fetchAccount),
|
||||||
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -278,6 +300,9 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
|
|
||||||
for (final message in messages) {
|
for (final message in messages) {
|
||||||
await _database.saveMessage(_database.messageToCompanion(message));
|
await _database.saveMessage(_database.messageToCompanion(message));
|
||||||
|
if (message.sender != null) {
|
||||||
|
await _database.saveMember(message.sender!); // Save/update member data
|
||||||
|
}
|
||||||
if (message.nonce != null) {
|
if (message.nonce != null) {
|
||||||
_pendingMessages.removeWhere(
|
_pendingMessages.removeWhere(
|
||||||
(_, pendingMsg) => pendingMsg.nonce == message.nonce,
|
(_, pendingMsg) => pendingMsg.nonce == message.nonce,
|
||||||
@@ -306,7 +331,10 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
final lastMessage =
|
final lastMessage =
|
||||||
dbMessages.isEmpty
|
dbMessages.isEmpty
|
||||||
? null
|
? null
|
||||||
: await _database.companionToMessage(dbMessages.first);
|
: await _database.companionToMessage(
|
||||||
|
dbMessages.first,
|
||||||
|
fetchAccount: _fetchAccount,
|
||||||
|
);
|
||||||
|
|
||||||
if (lastMessage == null) {
|
if (lastMessage == null) {
|
||||||
talker.log('No local messages, fetching from network');
|
talker.log('No local messages, fetching from network');
|
||||||
@@ -474,6 +502,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
_pendingMessages[localMessage.id] = localMessage;
|
_pendingMessages[localMessage.id] = localMessage;
|
||||||
_fileUploadProgress[localMessage.id] = {};
|
_fileUploadProgress[localMessage.id] = {};
|
||||||
await _database.saveMessage(_database.messageToCompanion(localMessage));
|
await _database.saveMessage(_database.messageToCompanion(localMessage));
|
||||||
|
await _database.saveMember(mockMessage.sender);
|
||||||
|
|
||||||
final currentMessages = state.value ?? [];
|
final currentMessages = state.value ?? [];
|
||||||
state = AsyncValue.data([localMessage, ...currentMessages]);
|
state = AsyncValue.data([localMessage, ...currentMessages]);
|
||||||
@@ -894,7 +923,10 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
await (_database.select(_database.chatMessages)
|
await (_database.select(_database.chatMessages)
|
||||||
..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull();
|
..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull();
|
||||||
if (localMessage != null) {
|
if (localMessage != null) {
|
||||||
return _database.companionToMessage(localMessage);
|
return _database.companionToMessage(
|
||||||
|
localMessage,
|
||||||
|
fetchAccount: _fetchAccount,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final response = await _apiClient.get(
|
final response = await _apiClient.get(
|
||||||
|
|||||||
@@ -148,9 +148,6 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
final inputKey = useMemoized(() => GlobalKey());
|
final inputKey = useMemoized(() => GlobalKey());
|
||||||
final inputHeight = useState<double>(80.0);
|
final inputHeight = useState<double>(80.0);
|
||||||
|
|
||||||
// Track previous height for smooth animations
|
|
||||||
final previousInputHeight = usePrevious<double>(inputHeight.value);
|
|
||||||
|
|
||||||
// Periodic height measurement for dynamic sizing
|
// Periodic height measurement for dynamic sizing
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
final timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
|
final timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
|
||||||
@@ -624,428 +621,179 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
|
Widget chatMessageListWidget(
|
||||||
previousInputHeight != null && previousInputHeight != inputHeight.value
|
List<LocalChatMessage> messageList,
|
||||||
? TweenAnimationBuilder<double>(
|
) => AnimatedPadding(
|
||||||
tween: Tween<double>(
|
duration: const Duration(milliseconds: 200),
|
||||||
begin: previousInputHeight,
|
curve: Curves.easeOut,
|
||||||
end: inputHeight.value,
|
padding: EdgeInsets.only(
|
||||||
),
|
top: 16,
|
||||||
duration: const Duration(milliseconds: 200),
|
bottom: MediaQuery.of(context).padding.bottom + 8 + inputHeight.value,
|
||||||
curve: Curves.easeOut,
|
),
|
||||||
builder:
|
child: SuperListView.builder(
|
||||||
(context, height, child) => SuperListView.builder(
|
listController: listController,
|
||||||
listController: listController,
|
controller: scrollController,
|
||||||
padding: EdgeInsets.only(
|
reverse: true, // Show newest messages at the bottom
|
||||||
top: 16,
|
itemCount: messageList.length,
|
||||||
bottom:
|
findChildIndexCallback: (key) {
|
||||||
MediaQuery.of(context).padding.bottom + 8 + height,
|
if (key is! ValueKey<String>) return null;
|
||||||
),
|
final messageId = key.value.substring(messageKeyPrefix.length);
|
||||||
controller: scrollController,
|
final index = messageList.indexWhere(
|
||||||
reverse: true, // Show newest messages at the bottom
|
(m) => (m.nonce ?? m.id) == messageId,
|
||||||
itemCount: messageList.length,
|
);
|
||||||
findChildIndexCallback: (key) {
|
return index >= 0 ? index : null;
|
||||||
if (key is! ValueKey<String>) return null;
|
},
|
||||||
final messageId = key.value.substring(
|
extentEstimation: (_, _) => 40,
|
||||||
messageKeyPrefix.length,
|
itemBuilder: (context, index) {
|
||||||
);
|
final message = messageList[index];
|
||||||
final index = messageList.indexWhere(
|
final nextMessage =
|
||||||
(m) => (m.nonce ?? m.id) == messageId,
|
index < messageList.length - 1 ? messageList[index + 1] : null;
|
||||||
);
|
final isLastInGroup =
|
||||||
// Return null for invalid indices to let SuperListView handle it properly
|
nextMessage == null ||
|
||||||
return index >= 0 ? index : null;
|
nextMessage.senderId != message.senderId ||
|
||||||
},
|
nextMessage.createdAt
|
||||||
extentEstimation: (_, _) => 40,
|
.difference(message.createdAt)
|
||||||
itemBuilder: (context, index) {
|
.inMinutes
|
||||||
final message = messageList[index];
|
.abs() >
|
||||||
final nextMessage =
|
3;
|
||||||
index < messageList.length - 1
|
|
||||||
? messageList[index + 1]
|
|
||||||
: null;
|
|
||||||
final isLastInGroup =
|
|
||||||
nextMessage == null ||
|
|
||||||
nextMessage.senderId != message.senderId ||
|
|
||||||
nextMessage.createdAt
|
|
||||||
.difference(message.createdAt)
|
|
||||||
.inMinutes
|
|
||||||
.abs() >
|
|
||||||
3;
|
|
||||||
|
|
||||||
// Use a stable animation key that doesn't change during message lifecycle
|
final key = Key('$messageKeyPrefix${message.nonce ?? message.id}');
|
||||||
final key = Key(
|
|
||||||
'$messageKeyPrefix${message.nonce ?? message.id}',
|
|
||||||
);
|
|
||||||
|
|
||||||
final messageWidget = chatIdentity.when(
|
final messageWidget = chatIdentity.when(
|
||||||
skipError: true,
|
skipError: true,
|
||||||
data:
|
data:
|
||||||
(identity) => GestureDetector(
|
(identity) => GestureDetector(
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
if (!isSelectionMode.value) {
|
if (!isSelectionMode.value) {
|
||||||
toggleSelectionMode();
|
toggleSelectionMode();
|
||||||
toggleMessageSelection(message.id);
|
toggleMessageSelection(message.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (isSelectionMode.value) {
|
if (isSelectionMode.value) {
|
||||||
toggleMessageSelection(message.id);
|
toggleMessageSelection(message.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
color:
|
color:
|
||||||
selectedMessages.value.contains(message.id)
|
selectedMessages.value.contains(message.id)
|
||||||
? Theme.of(context)
|
? Theme.of(
|
||||||
.colorScheme
|
context,
|
||||||
.primaryContainer
|
).colorScheme.primaryContainer.withOpacity(0.3)
|
||||||
.withOpacity(0.3)
|
: null,
|
||||||
: null,
|
child: Stack(
|
||||||
child: Stack(
|
children: [
|
||||||
children: [
|
MessageItem(
|
||||||
MessageItem(
|
key: settings.disableAnimation ? key : null,
|
||||||
key:
|
message: message,
|
||||||
settings.disableAnimation
|
isCurrentUser: identity?.id == message.senderId,
|
||||||
? key
|
onAction:
|
||||||
: null,
|
isSelectionMode.value
|
||||||
message: message,
|
? null
|
||||||
isCurrentUser:
|
: (action) {
|
||||||
identity?.id == message.senderId,
|
switch (action) {
|
||||||
onAction:
|
case MessageItemAction.delete:
|
||||||
isSelectionMode.value
|
messagesNotifier.deleteMessage(
|
||||||
? null
|
message.id,
|
||||||
: (action) {
|
|
||||||
switch (action) {
|
|
||||||
case MessageItemAction.delete:
|
|
||||||
messagesNotifier
|
|
||||||
.deleteMessage(
|
|
||||||
message.id,
|
|
||||||
);
|
|
||||||
case MessageItemAction.edit:
|
|
||||||
messageEditingTo.value =
|
|
||||||
message
|
|
||||||
.toRemoteMessage();
|
|
||||||
messageController.text =
|
|
||||||
messageEditingTo
|
|
||||||
.value
|
|
||||||
?.content ??
|
|
||||||
'';
|
|
||||||
attachments.value =
|
|
||||||
messageEditingTo
|
|
||||||
.value!
|
|
||||||
.attachments
|
|
||||||
.map(
|
|
||||||
(e) =>
|
|
||||||
UniversalFile.fromAttachment(
|
|
||||||
e,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
case MessageItemAction
|
|
||||||
.forward:
|
|
||||||
messageForwardingTo.value =
|
|
||||||
message
|
|
||||||
.toRemoteMessage();
|
|
||||||
case MessageItemAction.reply:
|
|
||||||
messageReplyingTo.value =
|
|
||||||
message
|
|
||||||
.toRemoteMessage();
|
|
||||||
case MessageItemAction.resend:
|
|
||||||
messagesNotifier
|
|
||||||
.retryMessage(
|
|
||||||
message.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onJump: (messageId) {
|
|
||||||
scrollToMessage(
|
|
||||||
messageId: messageId,
|
|
||||||
messageList: messageList,
|
|
||||||
messagesNotifier: messagesNotifier,
|
|
||||||
listController: listController,
|
|
||||||
scrollController: scrollController,
|
|
||||||
ref: ref,
|
|
||||||
);
|
);
|
||||||
},
|
case MessageItemAction.edit:
|
||||||
progress:
|
messageEditingTo.value =
|
||||||
attachmentProgress.value[message.id],
|
message.toRemoteMessage();
|
||||||
showAvatar: isLastInGroup,
|
messageController.text =
|
||||||
isSelectionMode: isSelectionMode.value,
|
messageEditingTo.value?.content ??
|
||||||
isSelected: selectedMessages.value
|
'';
|
||||||
.contains(message.id),
|
attachments.value =
|
||||||
onToggleSelection: toggleMessageSelection,
|
messageEditingTo.value!.attachments
|
||||||
onEnterSelectionMode: () {
|
.map(
|
||||||
if (!isSelectionMode.value) {
|
(e) =>
|
||||||
toggleSelectionMode();
|
UniversalFile.fromAttachment(
|
||||||
}
|
e,
|
||||||
},
|
),
|
||||||
),
|
)
|
||||||
if (selectedMessages.value.contains(
|
.toList();
|
||||||
message.id,
|
case MessageItemAction.forward:
|
||||||
))
|
messageForwardingTo.value =
|
||||||
...([
|
message.toRemoteMessage();
|
||||||
Positioned(
|
case MessageItemAction.reply:
|
||||||
top: 8,
|
messageReplyingTo.value =
|
||||||
right: 8,
|
message.toRemoteMessage();
|
||||||
child: Container(
|
case MessageItemAction.resend:
|
||||||
width: 16,
|
messagesNotifier.retryMessage(
|
||||||
height: 16,
|
message.id,
|
||||||
decoration: BoxDecoration(
|
);
|
||||||
color:
|
}
|
||||||
Theme.of(
|
},
|
||||||
context,
|
onJump:
|
||||||
).colorScheme.primary,
|
(messageId) => scrollToMessage(
|
||||||
shape: BoxShape.circle,
|
messageId: messageId,
|
||||||
),
|
messageList: messageList,
|
||||||
child: Icon(
|
messagesNotifier: messagesNotifier,
|
||||||
Icons.check,
|
listController: listController,
|
||||||
size: 12,
|
scrollController: scrollController,
|
||||||
color:
|
ref: ref,
|
||||||
Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
progress: attachmentProgress.value[message.id],
|
||||||
loading:
|
showAvatar: isLastInGroup,
|
||||||
() => MessageItem(
|
isSelectionMode: isSelectionMode.value,
|
||||||
message: message,
|
isSelected: selectedMessages.value.contains(
|
||||||
isCurrentUser: false,
|
message.id,
|
||||||
onAction: null,
|
|
||||||
progress: null,
|
|
||||||
showAvatar: false,
|
|
||||||
onJump: (_) {},
|
|
||||||
),
|
|
||||||
error: (_, _) => const SizedBox.shrink(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return settings.disableAnimation
|
|
||||||
? messageWidget
|
|
||||||
: TweenAnimationBuilder<double>(
|
|
||||||
key: key,
|
|
||||||
tween: Tween<double>(begin: 0.0, end: 1.0),
|
|
||||||
duration: Duration(
|
|
||||||
milliseconds: 400 + (index % 5) * 50,
|
|
||||||
), // Staggered delay
|
|
||||||
curve: Curves.easeOutCubic,
|
|
||||||
builder: (context, animationValue, child) {
|
|
||||||
return Transform.translate(
|
|
||||||
offset: Offset(
|
|
||||||
0,
|
|
||||||
20 * (1 - animationValue),
|
|
||||||
), // Slide up from bottom
|
|
||||||
child: Opacity(
|
|
||||||
opacity: animationValue,
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: messageWidget,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: SuperListView.builder(
|
|
||||||
listController: listController,
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
top: 16,
|
|
||||||
bottom:
|
|
||||||
MediaQuery.of(context).padding.bottom +
|
|
||||||
8 +
|
|
||||||
inputHeight.value,
|
|
||||||
),
|
|
||||||
controller: scrollController,
|
|
||||||
reverse: true, // Show newest messages at the bottom
|
|
||||||
itemCount: messageList.length,
|
|
||||||
findChildIndexCallback: (key) {
|
|
||||||
if (key is! ValueKey<String>) return null;
|
|
||||||
final messageId = key.value.substring(messageKeyPrefix.length);
|
|
||||||
final index = messageList.indexWhere(
|
|
||||||
(m) => (m.nonce ?? m.id) == messageId,
|
|
||||||
);
|
|
||||||
// Return null for invalid indices to let SuperListView handle it properly
|
|
||||||
return index >= 0 ? index : null;
|
|
||||||
},
|
|
||||||
extentEstimation: (_, _) => 40,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final message = messageList[index];
|
|
||||||
final nextMessage =
|
|
||||||
index < messageList.length - 1
|
|
||||||
? messageList[index + 1]
|
|
||||||
: null;
|
|
||||||
final isLastInGroup =
|
|
||||||
nextMessage == null ||
|
|
||||||
nextMessage.senderId != message.senderId ||
|
|
||||||
nextMessage.createdAt
|
|
||||||
.difference(message.createdAt)
|
|
||||||
.inMinutes
|
|
||||||
.abs() >
|
|
||||||
3;
|
|
||||||
|
|
||||||
// Use a stable animation key that doesn't change during message lifecycle
|
|
||||||
final key = Key(
|
|
||||||
'$messageKeyPrefix${message.nonce ?? message.id}',
|
|
||||||
);
|
|
||||||
|
|
||||||
final messageWidget = chatIdentity.when(
|
|
||||||
skipError: true,
|
|
||||||
data:
|
|
||||||
(identity) => GestureDetector(
|
|
||||||
onLongPress: () {
|
|
||||||
if (!isSelectionMode.value) {
|
|
||||||
toggleSelectionMode();
|
|
||||||
toggleMessageSelection(message.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onTap: () {
|
|
||||||
if (isSelectionMode.value) {
|
|
||||||
toggleMessageSelection(message.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
color:
|
|
||||||
selectedMessages.value.contains(message.id)
|
|
||||||
? Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.primaryContainer
|
|
||||||
.withOpacity(0.3)
|
|
||||||
: null,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
MessageItem(
|
|
||||||
key: settings.disableAnimation ? key : null,
|
|
||||||
message: message,
|
|
||||||
isCurrentUser: identity?.id == message.senderId,
|
|
||||||
onAction:
|
|
||||||
isSelectionMode.value
|
|
||||||
? null
|
|
||||||
: (action) {
|
|
||||||
switch (action) {
|
|
||||||
case MessageItemAction.delete:
|
|
||||||
messagesNotifier.deleteMessage(
|
|
||||||
message.id,
|
|
||||||
);
|
|
||||||
case MessageItemAction.edit:
|
|
||||||
messageEditingTo.value =
|
|
||||||
message.toRemoteMessage();
|
|
||||||
messageController.text =
|
|
||||||
messageEditingTo
|
|
||||||
.value
|
|
||||||
?.content ??
|
|
||||||
'';
|
|
||||||
attachments.value =
|
|
||||||
messageEditingTo
|
|
||||||
.value!
|
|
||||||
.attachments
|
|
||||||
.map(
|
|
||||||
(e) =>
|
|
||||||
UniversalFile.fromAttachment(
|
|
||||||
e,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
case MessageItemAction.forward:
|
|
||||||
messageForwardingTo.value =
|
|
||||||
message.toRemoteMessage();
|
|
||||||
case MessageItemAction.reply:
|
|
||||||
messageReplyingTo.value =
|
|
||||||
message.toRemoteMessage();
|
|
||||||
case MessageItemAction.resend:
|
|
||||||
messagesNotifier.retryMessage(
|
|
||||||
message.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onJump: (messageId) {
|
|
||||||
scrollToMessage(
|
|
||||||
messageId: messageId,
|
|
||||||
messageList: messageList,
|
|
||||||
messagesNotifier: messagesNotifier,
|
|
||||||
listController: listController,
|
|
||||||
scrollController: scrollController,
|
|
||||||
ref: ref,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
progress: attachmentProgress.value[message.id],
|
|
||||||
showAvatar: isLastInGroup,
|
|
||||||
isSelectionMode: isSelectionMode.value,
|
|
||||||
isSelected: selectedMessages.value.contains(
|
|
||||||
message.id,
|
|
||||||
),
|
|
||||||
onToggleSelection: toggleMessageSelection,
|
|
||||||
onEnterSelectionMode: () {
|
|
||||||
if (!isSelectionMode.value) {
|
|
||||||
toggleSelectionMode();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (selectedMessages.value.contains(message.id))
|
|
||||||
...([
|
|
||||||
Positioned(
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
child: Container(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color:
|
|
||||||
Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.primary,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.check,
|
|
||||||
size: 12,
|
|
||||||
color:
|
|
||||||
Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
onToggleSelection: toggleMessageSelection,
|
||||||
|
onEnterSelectionMode: () {
|
||||||
|
if (!isSelectionMode.value) toggleSelectionMode();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
if (selectedMessages.value.contains(message.id))
|
||||||
loading:
|
Positioned(
|
||||||
() => MessageItem(
|
top: 8,
|
||||||
message: message,
|
right: 8,
|
||||||
isCurrentUser: false,
|
child: Container(
|
||||||
onAction: null,
|
width: 16,
|
||||||
progress: null,
|
height: 16,
|
||||||
showAvatar: false,
|
decoration: BoxDecoration(
|
||||||
onJump: (_) {},
|
color: Theme.of(context).colorScheme.primary,
|
||||||
),
|
shape: BoxShape.circle,
|
||||||
error: (_, _) => const SizedBox.shrink(),
|
),
|
||||||
);
|
child: Icon(
|
||||||
|
Icons.check,
|
||||||
|
size: 12,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
loading:
|
||||||
|
() => MessageItem(
|
||||||
|
message: message,
|
||||||
|
isCurrentUser: false,
|
||||||
|
onAction: null,
|
||||||
|
progress: null,
|
||||||
|
showAvatar: false,
|
||||||
|
onJump: (_) {},
|
||||||
|
),
|
||||||
|
error: (_, _) => const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
|
||||||
return settings.disableAnimation
|
return settings.disableAnimation
|
||||||
? messageWidget
|
? messageWidget
|
||||||
: TweenAnimationBuilder<double>(
|
: TweenAnimationBuilder<double>(
|
||||||
key: key,
|
key: key,
|
||||||
tween: Tween<double>(begin: 0.0, end: 1.0),
|
tween: Tween<double>(begin: 0.0, end: 1.0),
|
||||||
duration: Duration(
|
duration: Duration(milliseconds: 400 + (index % 5) * 50),
|
||||||
milliseconds: 400 + (index % 5) * 50,
|
curve: Curves.easeOutCubic,
|
||||||
), // Staggered delay
|
builder:
|
||||||
curve: Curves.easeOutCubic,
|
(context, animationValue, child) => Transform.translate(
|
||||||
builder: (context, animationValue, child) {
|
offset: Offset(0, 20 * (1 - animationValue)),
|
||||||
return Transform.translate(
|
child: Opacity(opacity: animationValue, child: child),
|
||||||
offset: Offset(
|
),
|
||||||
0,
|
child: messageWidget,
|
||||||
20 * (1 - animationValue),
|
);
|
||||||
), // Slide up from bottom
|
},
|
||||||
child: Opacity(opacity: animationValue, child: child),
|
),
|
||||||
);
|
);
|
||||||
},
|
|
||||||
child: messageWidget,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|||||||
@@ -314,9 +314,6 @@ class AppScaffold extends HookConsumerWidget {
|
|||||||
return null;
|
return null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
final appBarHeight = appBar?.preferredSize.height ?? 0;
|
|
||||||
final safeTop = MediaQuery.of(context).padding.top;
|
|
||||||
|
|
||||||
final noBackground = isNoBackground ?? isWideScreen(context);
|
final noBackground = isNoBackground ?? isWideScreen(context);
|
||||||
|
|
||||||
final builtWidget = Focus(
|
final builtWidget = Focus(
|
||||||
@@ -325,16 +322,7 @@ class AppScaffold extends HookConsumerWidget {
|
|||||||
extendBody: extendBody ?? true,
|
extendBody: extendBody ?? true,
|
||||||
extendBodyBehindAppBar: true,
|
extendBodyBehindAppBar: true,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: Column(
|
body: body,
|
||||||
children: [
|
|
||||||
IgnorePointer(
|
|
||||||
child: SizedBox(
|
|
||||||
height: appBar != null ? appBarHeight + safeTop : 0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (body != null) Expanded(child: body!),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
appBar: appBar,
|
appBar: appBar,
|
||||||
bottomNavigationBar: bottomNavigationBar,
|
bottomNavigationBar: bottomNavigationBar,
|
||||||
bottomSheet: bottomSheet,
|
bottomSheet: bottomSheet,
|
||||||
|
|||||||
Reference in New Issue
Block a user