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>
186 lines
6.2 KiB
Dart
186 lines
6.2 KiB
Dart
// 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
|
|
// - Web: OPFS (Origin Private File System) via IndexedDB fallback
|
|
|
|
import 'package:drift/drift.dart';
|
|
import 'package:drift_flutter/drift_flutter.dart';
|
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
|
|
part 'database.g.dart';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Table definitions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Persisted room state. Upserted on every sync.
|
|
class RoomsTable extends Table {
|
|
@override
|
|
String get tableName => 'rooms';
|
|
|
|
TextColumn get id => text()();
|
|
TextColumn get name => text()();
|
|
TextColumn get avatarUrl => text().nullable()();
|
|
TextColumn get lastMessage => text().nullable()();
|
|
IntColumn get lastActivityAt =>
|
|
integer().nullable()(); // milliseconds since epoch
|
|
IntColumn get unreadCount => integer().withDefault(const Constant(0))();
|
|
BoolColumn get isDm => boolean().withDefault(const Constant(false))();
|
|
|
|
@override
|
|
Set<Column<Object>> get primaryKey => {id};
|
|
}
|
|
|
|
/// 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';
|
|
|
|
TextColumn get id => text()(); // eventId
|
|
TextColumn get roomId => text()();
|
|
TextColumn get senderId => text()();
|
|
TextColumn get body => text().nullable()();
|
|
TextColumn get type => text()(); // MessageType name
|
|
IntColumn get timestamp => integer()(); // milliseconds since epoch
|
|
|
|
@override
|
|
Set<Column<Object>> get primaryKey => {id};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Database class
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@DriftDatabase(tables: [RoomsTable, MessagesTable])
|
|
class AppDatabase extends _$AppDatabase {
|
|
AppDatabase() : super(driftDatabase(name: 'm8chat'));
|
|
|
|
// Separate constructor for testing — accepts a custom executor.
|
|
AppDatabase.forTesting(super.executor);
|
|
|
|
@override
|
|
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();
|
|
},
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DAOs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Data access for rooms.
|
|
extension RoomsDao on AppDatabase {
|
|
/// Insert or replace a room row.
|
|
Future<void> upsertRoom(RoomsTableCompanion room) =>
|
|
into(roomsTable).insertOnConflictUpdate(room);
|
|
|
|
/// Watch all rooms ordered by unread count desc then last activity desc.
|
|
Stream<List<RoomsTableData>> watchAllRooms() {
|
|
return (select(roomsTable)..orderBy([
|
|
(t) =>
|
|
OrderingTerm(expression: t.unreadCount, mode: OrderingMode.desc),
|
|
(t) => OrderingTerm(
|
|
expression: t.lastActivityAt,
|
|
mode: OrderingMode.desc,
|
|
nulls: NullsOrder.last,
|
|
),
|
|
]))
|
|
.watch();
|
|
}
|
|
|
|
/// Get a single room by id, or null if not found.
|
|
Future<RoomsTableData?> getRoomById(String id) =>
|
|
(select(roomsTable)..where((t) => t.id.equals(id))).getSingleOrNull();
|
|
}
|
|
|
|
/// Data access for messages.
|
|
extension MessagesDao on AppDatabase {
|
|
/// Insert or replace a message row.
|
|
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)
|
|
..where((t) => t.roomId.equals(roomId))
|
|
..orderBy([
|
|
(t) =>
|
|
OrderingTerm(expression: t.timestamp, mode: OrderingMode.asc),
|
|
]))
|
|
.watch();
|
|
}
|
|
|
|
/// Load one page of messages for [roomId], most recent first.
|
|
Future<List<MessagesTableData>> getPage(
|
|
String roomId, {
|
|
int limit = 50,
|
|
int offset = 0,
|
|
}) {
|
|
return (select(messagesTable)
|
|
..where((t) => t.roomId.equals(roomId))
|
|
..orderBy([
|
|
(t) =>
|
|
OrderingTerm(expression: t.timestamp, mode: OrderingMode.desc),
|
|
])
|
|
..limit(limit, offset: offset))
|
|
.get();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Riverpod provider
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Provides the singleton [AppDatabase]. keepAlive: true — opened once for
|
|
/// the app lifetime.
|
|
@Riverpod(keepAlive: true)
|
|
AppDatabase appDatabase(Ref ref) {
|
|
final db = AppDatabase();
|
|
// Close the database when the provider is finally disposed (app shutdown).
|
|
ref.onDispose(db.close);
|
|
return db;
|
|
}
|