🐛 Fix bugs in message db

This commit is contained in:
2025-11-20 00:01:36 +08:00
parent 18e890d63c
commit 18d16fdd57
5 changed files with 283 additions and 450 deletions

View File

@@ -160,6 +160,7 @@ class AppDatabase extends _$AppDatabase {
String roomId,
String query, {
bool? withAttachments,
Future<SnAccount?> Function(String accountId)? fetchAccount,
}) async {
var selectStatement = select(chatMessages)
..where((m) => m.roomId.equals(roomId));
@@ -186,7 +187,9 @@ class AppDatabase extends _$AppDatabase {
..orderBy([(m) => OrderingTerm.desc(m.createdAt)]))
.get();
final messageFutures =
messages.map((msg) => companionToMessage(msg)).toList();
messages
.map((msg) => companionToMessage(msg, fetchAccount: fetchAccount))
.toList();
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);
SnChatMember? sender;
try {
final senderRow =
await (select(chatMembers)
..where((m) => m.id.equals(dbMessage.senderId))).getSingle();
final senderAccount = SnAccount.fromJson(senderRow.account);
SnAccountStatus? senderStatus;
if (senderRow.status != null) {
senderStatus = SnAccountStatus.fromJson(jsonDecode(senderRow.status!));
}
SnAccount senderAccount;
senderAccount = SnAccount.fromJson(senderRow.account);
sender = SnChatMember(
id: senderRow.id,
chatRoomId: senderRow.chatRoomId,
@@ -239,15 +243,57 @@ class AppDatabase extends _$AppDatabase {
breakUntil: senderRow.breakUntil,
timeoutUntil: senderRow.timeoutUntil,
isBot: senderRow.isBot,
status: senderStatus,
status: null,
lastTyped: senderRow.lastTyped,
createdAt: senderRow.createdAt,
updatedAt: senderRow.updatedAt,
deletedAt: senderRow.deletedAt,
chatRoom: null,
);
} catch (_) {
sender = null;
} catch (err) {
// 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(
id: dbMessage.id,
@@ -377,4 +423,10 @@ class AppDatabase extends _$AppDatabase {
return await (select(postDrafts)
..where((tbl) => tbl.id.equals(id))).getSingleOrNull();
}
Future<void> saveMember(SnChatMember member) async {
await into(
chatMembers,
).insert(companionFromMember(member), mode: InsertMode.insertOrReplace);
}
}

View File

@@ -158,10 +158,11 @@ class LocalChatMessage {
});
SnChatMessage toRemoteMessage() {
final msgData = Map<String, dynamic>.from(data);
if (sender != null) {
msgData['sender'] = sender!.toJson();
if (sender == null) {
throw Exception('Cannot create remote message without sender');
}
final msgData = Map<String, dynamic>.from(data);
msgData['sender'] = sender!.toJson();
return SnChatMessage.fromJson(msgData);
}
@@ -170,8 +171,20 @@ class LocalChatMessage {
MessageStatus status, {
String? nonce,
}) {
final msgData = Map<String, dynamic>.from(message.toJson())
..remove('sender');
final jsonData = message.toJson();
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(
id: message.id,
roomId: message.chatRoomId,

View File

@@ -6,6 +6,7 @@ import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/database/drift_db.dart";
import "package:island/database/message.dart";
import "package:island/models/account.dart";
import "package:island/models/chat.dart";
import "package:island/models/file.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:island/screens/chat/chat.dart";
import "package:island/pods/chat/chat_rooms.dart";
import "package:island/screens/account/profile.dart";
part 'messages_notifier.g.dart';
@@ -45,6 +47,8 @@ class MessagesNotifier extends _$MessagesNotifier {
bool _isUpdatingState = false;
DateTime? _lastPauseTime;
late final Future<SnAccount?> Function(String) _fetchAccount;
@override
FutureOr<List<LocalChatMessage>> build(String roomId) async {
_roomId = roomId;
@@ -53,6 +57,15 @@ class MessagesNotifier extends _$MessagesNotifier {
final room = await ref.watch(chatroomProvider(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) {
throw Exception('Room not found');
}
@@ -133,6 +146,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_roomId,
searchQuery,
withAttachments: withAttachments,
fetchAccount: _fetchAccount,
);
} else {
final chatMessagesFromDb = await _database.getMessagesForRoom(
@@ -142,7 +156,12 @@ class MessagesNotifier extends _$MessagesNotifier {
);
dbMessages = await Future.wait(
chatMessagesFromDb
.map((msg) => _database.companionToMessage(msg))
.map(
(msg) => _database.companionToMessage(
msg,
fetchAccount: _fetchAccount,
),
)
.toList(),
);
}
@@ -207,7 +226,10 @@ class MessagesNotifier extends _$MessagesNotifier {
);
final dbMessages = await Future.wait(
chatMessagesFromDb
.map((msg) => _database.companionToMessage(msg))
.map(
(msg) =>
_database.companionToMessage(msg, fetchAccount: _fetchAccount),
)
.toList(),
);
@@ -278,6 +300,9 @@ class MessagesNotifier extends _$MessagesNotifier {
for (final message in messages) {
await _database.saveMessage(_database.messageToCompanion(message));
if (message.sender != null) {
await _database.saveMember(message.sender!); // Save/update member data
}
if (message.nonce != null) {
_pendingMessages.removeWhere(
(_, pendingMsg) => pendingMsg.nonce == message.nonce,
@@ -306,7 +331,10 @@ class MessagesNotifier extends _$MessagesNotifier {
final lastMessage =
dbMessages.isEmpty
? null
: await _database.companionToMessage(dbMessages.first);
: await _database.companionToMessage(
dbMessages.first,
fetchAccount: _fetchAccount,
);
if (lastMessage == null) {
talker.log('No local messages, fetching from network');
@@ -474,6 +502,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_pendingMessages[localMessage.id] = localMessage;
_fileUploadProgress[localMessage.id] = {};
await _database.saveMessage(_database.messageToCompanion(localMessage));
await _database.saveMember(mockMessage.sender);
final currentMessages = state.value ?? [];
state = AsyncValue.data([localMessage, ...currentMessages]);
@@ -894,7 +923,10 @@ class MessagesNotifier extends _$MessagesNotifier {
await (_database.select(_database.chatMessages)
..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull();
if (localMessage != null) {
return _database.companionToMessage(localMessage);
return _database.companionToMessage(
localMessage,
fetchAccount: _fetchAccount,
);
}
final response = await _apiClient.get(

View File

@@ -148,9 +148,6 @@ class ChatRoomScreen extends HookConsumerWidget {
final inputKey = useMemoized(() => GlobalKey());
final inputHeight = useState<double>(80.0);
// Track previous height for smooth animations
final previousInputHeight = usePrevious<double>(inputHeight.value);
// Periodic height measurement for dynamic sizing
useEffect(() {
final timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
@@ -624,428 +621,179 @@ class ChatRoomScreen extends HookConsumerWidget {
}
}
Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
previousInputHeight != null && previousInputHeight != inputHeight.value
? TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: previousInputHeight,
end: inputHeight.value,
),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
builder:
(context, height, child) => SuperListView.builder(
listController: listController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom + 8 + height,
),
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;
Widget chatMessageListWidget(
List<LocalChatMessage> messageList,
) => AnimatedPadding(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: EdgeInsets.only(
top: 16,
bottom: MediaQuery.of(context).padding.bottom + 8 + inputHeight.value,
),
child: SuperListView.builder(
listController: listController,
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 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 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,
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,
);
},
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,
),
),
),
]),
],
),
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,
),
),
loading:
() => MessageItem(
message: message,
isCurrentUser: false,
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,
),
),
),
]),
],
progress: attachmentProgress.value[message.id],
showAvatar: isLastInGroup,
isSelectionMode: isSelectionMode.value,
isSelected: selectedMessages.value.contains(
message.id,
),
onToggleSelection: toggleMessageSelection,
onEnterSelectionMode: () {
if (!isSelectionMode.value) toggleSelectionMode();
},
),
),
loading:
() => MessageItem(
message: message,
isCurrentUser: false,
onAction: null,
progress: null,
showAvatar: false,
onJump: (_) {},
),
error: (_, _) => const SizedBox.shrink(),
);
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,
),
),
),
],
),
),
),
loading:
() => MessageItem(
message: message,
isCurrentUser: false,
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,
);
},
);
return settings.disableAnimation
? messageWidget
: TweenAnimationBuilder<double>(
key: key,
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: Duration(milliseconds: 400 + (index % 5) * 50),
curve: Curves.easeOutCubic,
builder:
(context, animationValue, child) => Transform.translate(
offset: Offset(0, 20 * (1 - animationValue)),
child: Opacity(opacity: animationValue, child: child),
),
child: messageWidget,
);
},
),
);
return AppScaffold(
appBar: AppBar(

View File

@@ -314,9 +314,6 @@ class AppScaffold extends HookConsumerWidget {
return null;
}, []);
final appBarHeight = appBar?.preferredSize.height ?? 0;
final safeTop = MediaQuery.of(context).padding.top;
final noBackground = isNoBackground ?? isWideScreen(context);
final builtWidget = Focus(
@@ -325,16 +322,7 @@ class AppScaffold extends HookConsumerWidget {
extendBody: extendBody ?? true,
extendBodyBehindAppBar: true,
backgroundColor: Colors.transparent,
body: Column(
children: [
IgnorePointer(
child: SizedBox(
height: appBar != null ? appBarHeight + safeTop : 0,
),
),
if (body != null) Expanded(child: body!),
],
),
body: body,
appBar: appBar,
bottomNavigationBar: bottomNavigationBar,
bottomSheet: bottomSheet,