refactor: /simplify — 22 fixes from 3-agent code review
Critical: - Fix MXC URI resolution: all avatars/images now resolve mxc:// to HTTP - Sync persistence: only write changed rooms, batch message upserts - lastActivityAt uses room.lastEvent.originServerTs, not creation time High: - Shared MatrixAvatar widget replaces 6 duplicate implementations - CallScreen decodes roomId before LiveKit JWT fetch - Decline button actually dismisses incoming call overlay - EventTypes constants replace raw string literals - LiveKitService uses lazy auth reads, onDispose disconnects Medium: - CallController is keepAlive with timer/room cleanup - authRepository is keepAlive (used from keepAlive notifier) - StreamController not closed in stopListening (crash fix) - Index on messages.roomId for query performance - 400ms debounce on user search - Static DateFormat in MessageBubble - Hardcoded strings replaced with AppConfig refs - Duplicate isDirectMessage field removed from RoomModel - E2EE profile claim corrected to Phase 3 Shared utilities: - lib/shared/widgets/matrix_avatar.dart - lib/shared/utils/mxc_url.dart - lib/shared/utils/room_preview.dart - lib/shared/utils/matrix_id.dart rawJson column removed (unused, caused main-thread jsonEncode) Schema migrated to v2 with roomId index. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// Version: 1.1.0 | Created: 2026-04-01
|
||||
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02
|
||||
// Drift database — rooms and messages tables.
|
||||
// Uses driftDatabase() from drift_flutter for cross-platform support:
|
||||
// - Mobile/desktop: SQLite file via sqlite3_flutter_libs
|
||||
@@ -33,6 +33,7 @@ class RoomsTable extends Table {
|
||||
}
|
||||
|
||||
/// Persisted message events. Upserted when received from sync.
|
||||
@TableIndex(name: 'idx_messages_room_id', columns: {#roomId})
|
||||
class MessagesTable extends Table {
|
||||
@override
|
||||
String get tableName => 'messages';
|
||||
@@ -43,7 +44,6 @@ class MessagesTable extends Table {
|
||||
TextColumn get body => text().nullable()();
|
||||
TextColumn get type => text()(); // MessageType name
|
||||
IntColumn get timestamp => integer()(); // milliseconds since epoch
|
||||
TextColumn get rawJson => text()(); // full event JSON for future use
|
||||
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {id};
|
||||
@@ -61,7 +61,41 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase.forTesting(super.executor);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
int get schemaVersion => 2;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onUpgrade: (migrator, from, to) async {
|
||||
if (from < 2) {
|
||||
// v2: drop rawJson column, add index on roomId.
|
||||
// Drift cannot drop columns — recreate the table.
|
||||
await customStatement(
|
||||
'CREATE TABLE IF NOT EXISTS messages_new ('
|
||||
' id TEXT NOT NULL PRIMARY KEY, '
|
||||
' room_id TEXT NOT NULL, '
|
||||
' sender_id TEXT NOT NULL, '
|
||||
' body TEXT, '
|
||||
' type TEXT NOT NULL, '
|
||||
' timestamp INTEGER NOT NULL'
|
||||
')',
|
||||
);
|
||||
await customStatement(
|
||||
'INSERT OR IGNORE INTO messages_new '
|
||||
'(id, room_id, sender_id, body, type, timestamp) '
|
||||
'SELECT id, room_id, sender_id, body, type, timestamp FROM messages',
|
||||
);
|
||||
await customStatement('DROP TABLE IF EXISTS messages');
|
||||
await customStatement('ALTER TABLE messages_new RENAME TO messages');
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_messages_room_id '
|
||||
'ON messages (room_id)',
|
||||
);
|
||||
}
|
||||
},
|
||||
onCreate: (migrator) async {
|
||||
await migrator.createAll();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -99,6 +133,15 @@ extension MessagesDao on AppDatabase {
|
||||
Future<void> upsertMessage(MessagesTableCompanion message) =>
|
||||
into(messagesTable).insertOnConflictUpdate(message);
|
||||
|
||||
/// Insert or replace multiple message rows in a single batch transaction.
|
||||
Future<void> upsertMessages(List<MessagesTableCompanion> messages) async {
|
||||
await batch((b) {
|
||||
for (final msg in messages) {
|
||||
b.insert(messagesTable, msg, onConflict: DoUpdate((_) => msg));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Watch all messages for [roomId] ordered oldest-first.
|
||||
Stream<List<MessagesTableData>> watchByRoom(String roomId) {
|
||||
return (select(messagesTable)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Version: 1.1.0 | Created: 2026-04-01
|
||||
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02
|
||||
// SyncPersistenceService — listens to the Matrix sync stream and writes room
|
||||
// and message data into the Drift database.
|
||||
//
|
||||
@@ -7,12 +7,13 @@
|
||||
// forget on the Drift isolate.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../shared/utils/mxc_url.dart';
|
||||
import '../../shared/utils/room_preview.dart';
|
||||
import '../network/matrix_client.dart';
|
||||
import 'database.dart';
|
||||
|
||||
@@ -40,41 +41,57 @@ class SyncPersistenceService {
|
||||
}
|
||||
|
||||
Future<void> _onSync(SyncUpdate update) async {
|
||||
// Persist all rooms the client currently knows about.
|
||||
// We write every room on every sync — cheap upsert ensures we stay current.
|
||||
for (final room in _client.rooms) {
|
||||
await _db.upsertRoom(
|
||||
RoomsTableCompanion(
|
||||
id: Value(room.id),
|
||||
name: Value(room.getLocalizedDisplayname()),
|
||||
avatarUrl: Value(room.avatar?.toString()),
|
||||
lastMessage: Value(_lastMessagePreview(room)),
|
||||
lastActivityAt: Value(room.timeCreated.millisecondsSinceEpoch),
|
||||
unreadCount: Value(room.notificationCount),
|
||||
isDm: Value(room.isDirectChat),
|
||||
),
|
||||
);
|
||||
// Collect room IDs that changed in this sync batch.
|
||||
final changedIds = <String>{
|
||||
...?update.rooms?.join?.keys,
|
||||
...?update.rooms?.invite?.keys,
|
||||
...?update.rooms?.leave?.keys,
|
||||
};
|
||||
|
||||
// Only upsert rooms that actually changed — avoids O(n) writes.
|
||||
if (changedIds.isNotEmpty) {
|
||||
final roomCompanions = <RoomsTableCompanion>[];
|
||||
for (final roomId in changedIds) {
|
||||
final room = _client.getRoomById(roomId);
|
||||
if (room == null) continue;
|
||||
roomCompanions.add(
|
||||
RoomsTableCompanion(
|
||||
id: Value(room.id),
|
||||
name: Value(room.getLocalizedDisplayname()),
|
||||
avatarUrl: Value(resolveMxcUrl(_client, room.avatar)),
|
||||
lastMessage: Value(lastMessagePreview(room)),
|
||||
lastActivityAt: Value(
|
||||
(room.lastEvent?.originServerTs ?? room.timeCreated)
|
||||
.millisecondsSinceEpoch,
|
||||
),
|
||||
unreadCount: Value(room.notificationCount),
|
||||
isDm: Value(room.isDirectChat),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (final companion in roomCompanions) {
|
||||
await _db.upsertRoom(companion);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist new events from joined rooms in this sync batch.
|
||||
final joinedRooms = update.rooms?.join;
|
||||
if (joinedRooms == null) return;
|
||||
|
||||
final messageCompanions = <MessagesTableCompanion>[];
|
||||
|
||||
for (final entry in joinedRooms.entries) {
|
||||
final roomId = entry.key;
|
||||
final timeline = entry.value.timeline;
|
||||
if (timeline == null) continue;
|
||||
|
||||
// timeline.events is List<MatrixEvent>? — may be null for rooms with
|
||||
// no new events in this sync batch.
|
||||
final events = timeline.events;
|
||||
if (events == null) continue;
|
||||
|
||||
for (final event in events) {
|
||||
if (event.type != 'm.room.message') continue;
|
||||
if (event.type != EventTypes.Message) continue;
|
||||
|
||||
// eventId, senderId, originServerTs are all non-null on MatrixEvent.
|
||||
await _db.upsertMessage(
|
||||
messageCompanions.add(
|
||||
MessagesTableCompanion(
|
||||
id: Value(event.eventId),
|
||||
roomId: Value(roomId),
|
||||
@@ -82,22 +99,15 @@ class SyncPersistenceService {
|
||||
body: Value(event.content['body'] as String?),
|
||||
type: Value(event.content['msgtype'] as String? ?? 'unknown'),
|
||||
timestamp: Value(event.originServerTs.millisecondsSinceEpoch),
|
||||
rawJson: Value(jsonEncode(event.toJson())),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? _lastMessagePreview(Room room) {
|
||||
final lastEvent = room.lastEvent;
|
||||
if (lastEvent == null) return null;
|
||||
return switch (lastEvent.type) {
|
||||
'm.room.message' => lastEvent.body,
|
||||
'm.room.encrypted' => 'Encrypted message',
|
||||
'm.sticker' => 'Sticker',
|
||||
_ => null,
|
||||
};
|
||||
// Batch insert all messages in a single transaction.
|
||||
if (messageCompanions.isNotEmpty) {
|
||||
await _db.upsertMessages(messageCompanions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user