feat: keep timeline history for archive rooms in memory

This commit is contained in:
Henri Carnot 2022-07-21 14:14:17 +00:00 committed by td
parent f04d8a9f40
commit 2a019eaec3
6 changed files with 346 additions and 78 deletions

View File

@ -32,6 +32,7 @@ import 'package:matrix/src/utils/run_in_root.dart';
import 'package:matrix/src/utils/sync_update_item_count.dart';
import '../encryption.dart';
import '../matrix.dart';
import 'models/timeline_chunk.dart';
import 'utils/multilock.dart';
import 'utils/run_benchmarked.dart';
@ -287,7 +288,7 @@ class Client extends MatrixApi {
/// found. If you have loaded the [loadArchive()] before, it can also return
/// archived rooms.
Room? getRoomById(String id) {
for (final room in <Room>[...rooms, ..._archivedRooms]) {
for (final room in <Room>[...rooms, ..._archivedRooms.map((e) => e.room)]) {
if (room.id == id) return room;
}
@ -781,9 +782,35 @@ class Client extends MatrixApi {
avatarUrl: profile.avatarUrl);
}
final List<Room> _archivedRooms = [];
final List<ArchivedRoom> _archivedRooms = [];
/// Return an archive room containing the room and the timeline for a specific archived room.
ArchivedRoom? getArchiveRoomFromCache(String roomId) {
for (var i = 0; i < _archivedRooms.length; i++) {
final archive = _archivedRooms[i];
if (archive.room.id == roomId) return archive;
}
return null;
}
/// Remove all the archives stored in cache.
void clearArchivesFromCache() {
_archivedRooms.clear();
}
@Deprecated('Use [loadArchive()] instead.')
Future<List<Room>> get archive => loadArchive();
/// Fetch all the archived rooms from the server and return the list of the
/// room. If you want to have the Timelines bundled with it, use
/// loadArchiveWithTimeline instead.
Future<List<Room>> loadArchive() async {
return (await loadArchiveWithTimeline()).map((e) => e.room).toList();
}
/// Fetch the archived rooms from the server and return them as a list of
/// [ArchivedRoom] objects containing the [Room] and the associated [Timeline].
Future<List<ArchivedRoom>> loadArchiveWithTimeline() async {
_archivedRooms.clear();
final syncResp = await sync(
filter: '{"room":{"include_leave":true,"timeline":{"limit":10}}}',
@ -804,12 +831,32 @@ class Client extends MatrixApi {
<String, BasicRoomEvent>{},
);
final timeline = Timeline(
room: leftRoom,
chunk: TimelineChunk(
events: room.timeline?.events?.reversed
.toList() // we display the event in the other sence
.map((e) => Event.fromMatrixEvent(e, leftRoom))
.toList() ??
[]));
for (var i = 0; i < timeline.events.length; i++) {
// Try to decrypt encrypted events but don't update the database.
if (leftRoom.encrypted && leftRoom.client.encryptionEnabled) {
if (timeline.events[i].type == EventTypes.Encrypted) {
timeline.events[i] = await leftRoom.client.encryption!
.decryptRoomEvent(leftRoom.id, timeline.events[i]);
}
}
}
room.timeline?.events?.forEach((event) {
leftRoom.setState(Event.fromMatrixEvent(
event,
leftRoom,
));
});
leftRoom.prev_batch = room.timeline?.prevBatch;
room.state?.forEach((event) {
leftRoom.setState(Event.fromMatrixEvent(
@ -817,7 +864,8 @@ class Client extends MatrixApi {
leftRoom,
));
});
_archivedRooms.add(leftRoom);
_archivedRooms.add(ArchivedRoom(room: leftRoom, timeline: timeline));
}
}
return _archivedRooms;
@ -1658,6 +1706,12 @@ class Client extends MatrixApi {
await database?.storeRoomUpdate(id, syncRoomUpdate, this);
final room = _updateRoomsByRoomUpdate(id, syncRoomUpdate);
final timelineUpdateType = direction != null
? (direction == Direction.b
? EventUpdateType.history
: EventUpdateType.timeline)
: EventUpdateType.timeline;
/// Handle now all room events and save them in the database
if (syncRoomUpdate is JoinedRoomUpdate) {
final state = syncRoomUpdate.state;
@ -1673,14 +1727,7 @@ class Client extends MatrixApi {
final timelineEvents = syncRoomUpdate.timeline?.events;
if (timelineEvents != null && timelineEvents.isNotEmpty) {
await _handleRoomEvents(
room,
timelineEvents,
direction != null
? (direction == Direction.b
? EventUpdateType.history
: EventUpdateType.timeline)
: EventUpdateType.timeline);
await _handleRoomEvents(room, timelineEvents, timelineUpdateType);
}
final ephemeral = syncRoomUpdate.ephemeral;
@ -1705,23 +1752,19 @@ class Client extends MatrixApi {
if (syncRoomUpdate is LeftRoomUpdate) {
final timelineEvents = syncRoomUpdate.timeline?.events;
if (timelineEvents != null && timelineEvents.isNotEmpty) {
await _handleRoomEvents(
room,
timelineEvents,
EventUpdateType.timeline,
);
await _handleRoomEvents(room, timelineEvents, timelineUpdateType,
store: false);
}
final accountData = syncRoomUpdate.accountData;
if (accountData != null && accountData.isNotEmpty) {
await _handleRoomEvents(
room,
accountData,
EventUpdateType.accountData,
);
room, accountData, EventUpdateType.accountData,
store: false);
}
final state = syncRoomUpdate.state;
if (state != null && state.isNotEmpty) {
await _handleRoomEvents(room, state, EventUpdateType.state);
await _handleRoomEvents(room, state, EventUpdateType.state,
store: false);
}
}
@ -1795,10 +1838,8 @@ class Client extends MatrixApi {
}
Future<void> _handleRoomEvents(
Room room,
List<BasicEvent> events,
EventUpdateType type,
) async {
Room room, List<BasicEvent> events, EventUpdateType type,
{bool store = true}) async {
// Calling events can be omitted if they are outdated from the same sync. So
// we collect them first before we handle them.
final callEvents = <Event>{};
@ -1833,7 +1874,7 @@ class Client extends MatrixApi {
}
}
_updateRoomsByEventUpdate(room, update);
if (type != EventUpdateType.ephemeral) {
if (type != EventUpdateType.ephemeral && store) {
await database?.storeEventUpdate(update, this);
}
if (encryptionEnabled) {
@ -1941,6 +1982,10 @@ class Client extends MatrixApi {
// Does the chat already exist in the list rooms?
if (!found && membership != Membership.leave) {
// Check if the room is not in the rooms in the invited list
if (_archivedRooms.isNotEmpty) {
_archivedRooms.removeWhere((archive) => archive.room.id == roomId);
}
final position = membership == Membership.invite ? 0 : rooms.length;
// Add the new chat to the list
rooms.insert(position, room);
@ -2857,3 +2902,9 @@ class HomeserverSummary {
required this.loginFlows,
});
}
class ArchivedRoom {
final Room room;
final Timeline timeline;
ArchivedRoom({required this.room, required this.timeline});
}

View File

@ -1136,6 +1136,9 @@ class Room {
void Function()? onHistoryReceived,
direction = Direction.b}) async {
final prev_batch = this.prev_batch;
final storeInDatabase = !isArchived;
if (prev_batch == null) {
throw 'Tried to request history without a prev_batch token';
}
@ -1179,7 +1182,9 @@ class Room {
state: resp.state,
timeline: TimelineUpdate(
limited: false,
events: resp.chunk,
events: direction == Direction.b
? resp.chunk
: resp.chunk.reversed.toList(),
prevBatch: direction == Direction.b
? resp.end
: resp.start,
@ -1193,7 +1198,9 @@ class Room {
if (client.database != null) {
await client.database?.transaction(() async {
await client.database?.setRoomPrevBatch(resp.end!, id, client);
if (storeInDatabase) {
await client.database?.setRoomPrevBatch(resp.end!, id, client);
}
await loadFn();
});
} else {
@ -1304,6 +1311,9 @@ class Room {
return;
}
/// Is the room archived
bool get isArchived => membership == Membership.leave;
/// Creates a timeline from the store. Returns a [Timeline] object. If you
/// just want to update the whole timeline on every change, use the [onUpdate]
/// callback. For updating only the parts that have changed, use the
@ -1319,35 +1329,41 @@ class Room {
String? eventContextId}) async {
await postLoad();
final _events = await client.database?.getEventList(
this,
limit: defaultHistoryCount,
);
var events;
var chunk = TimelineChunk(events: _events ?? []);
if (!isArchived) {
events = await client.database?.getEventList(
this,
limit: defaultHistoryCount,
) ??
<Event>[];
} else {
final archive = client.getArchiveRoomFromCache(id);
events = archive?.timeline.events.toList() ?? [];
}
if (_events != null) {
if (eventContextId != null) {
if (_events
.firstWhereOrNull((event) => event.eventId == eventContextId) !=
null) {
chunk = TimelineChunk(events: _events);
} else {
chunk = await getEventContext(eventContextId) ??
TimelineChunk(events: []);
}
}
// Fetch all users from database we have got here.
if (eventContextId == null) {
for (final event in _events) {
if (getState(EventTypes.RoomMember, event.senderId) != null) continue;
final dbUser = await client.database?.getUser(event.senderId, this);
if (dbUser != null) setState(dbUser);
}
var chunk = TimelineChunk(events: events);
// Load the timeline arround eventContextId if set
if (eventContextId != null) {
if (events.firstWhereOrNull((event) => event.eventId == eventContextId) !=
null) {
chunk = TimelineChunk(events: events);
} else {
chunk =
await getEventContext(eventContextId) ?? TimelineChunk(events: []);
}
}
// Fetch all users from database we have got here.
if (eventContextId == null) {
for (final event in events) {
if (getState(EventTypes.RoomMember, event.senderId) != null) continue;
final dbUser = await client.database?.getUser(event.senderId, this);
if (dbUser != null) setState(dbUser);
}
}
// Try again to decrypt encrypted events and update the database.
if (encrypted && client.encryptionEnabled) {
// decrypt messages
for (var i = 0; i < chunk.events.length; i++) {
@ -1364,8 +1380,9 @@ class Room {
await client.database?.transaction(() async {
for (var i = 0; i < chunk.events.length; i++) {
if (chunk.events[i].content['can_request_session'] == true) {
chunk.events[i] = await client.encryption!
.decryptRoomEvent(id, chunk.events[i], store: true);
chunk.events[i] = await client.encryption!.decryptRoomEvent(
id, chunk.events[i],
store: !isArchived);
}
}
});

View File

@ -224,9 +224,7 @@ class Timeline {
}
// Try to decrypt encrypted events but don't update the database.
if (room.encrypted &&
room.client.database != null &&
room.client.encryptionEnabled) {
if (room.encrypted && room.client.encryptionEnabled) {
for (var i = 0; i < newEvents.length; i++) {
if (newEvents[i].type == EventTypes.Encrypted) {
newEvents[i] = await room.client.encryption!

View File

@ -338,21 +338,6 @@ void main() {
);
});
test('get archive', () async {
await matrix.abortSync();
final archive = await matrix.loadArchive();
expect(archive.length, 2);
expect(archive[0].id, '!5345234234:example.com');
expect(archive[0].membership, Membership.leave);
expect(archive[0].name, 'The room name');
expect(archive[0].lastEvent?.body, 'This is an example text message');
expect(archive[0].roomAccountData.length, 1);
expect(archive[1].id, '!5345234235:example.com');
expect(archive[1].membership, Membership.leave);
expect(archive[1].name, 'The room name 2');
});
test('sync state event in-memory handling', () async {
final roomId = '!726s6s6q:example.com';
final room = matrix.getRoomById(roomId)!;

View File

@ -361,6 +361,65 @@ class FakeMatrixApi extends BaseClient {
'state': [],
};
static Map<String, dynamic> archivesMessageResponse = {
'start': 't47429-4392820_219380_26003_2265',
'end': 't47409-4357353_219380_26003_2265',
'chunk': [
{
'content': {
'body': 'This is an example text message',
'msgtype': 'm.text',
'format': 'org.matrix.custom.html',
'formatted_body': '<b>This is an example text message</b>'
},
'type': 'm.room.message',
'event_id': '3143273582443PhrSn:example.org',
'room_id': '!5345234234:example.com',
'sender': '@example:example.org',
'origin_server_ts': 1432735824653,
'unsigned': {'age': 1234}
},
{
'content': {'name': 'The room name'},
'type': 'm.room.name',
'event_id': '2143273582443PhrSn:example.org',
'room_id': '!5345234234:example.com',
'sender': '@example:example.org',
'origin_server_ts': 1432735824653,
'unsigned': {'age': 1234},
'state_key': ''
},
{
'content': {
'body': 'Gangnam Style',
'url': 'mxc://example.org/a526eYUSFFxlgbQYZmo442',
'info': {
'thumbnail_url': 'mxc://example.org/FHyPlCeYUSFFxlgbQYZmoEoe',
'thumbnail_info': {
'mimetype': 'image/jpeg',
'size': 46144,
'w': 300,
'h': 300
},
'w': 480,
'h': 320,
'duration': 2140786,
'size': 1563685,
'mimetype': 'video/mp4'
},
'msgtype': 'm.video'
},
'type': 'm.room.message',
'event_id': '1143273582466PhrSn:example.org',
'room_id': '!5345234234:example.com',
'sender': '@example:example.org',
'origin_server_ts': 1432735824654,
'unsigned': {'age': 1234}
}
],
'state': [],
};
static Map<String, dynamic> syncResponse = {
'next_batch': Random().nextDouble().toString(),
'rooms': {
@ -888,19 +947,36 @@ class FakeMatrixApi extends BaseClient {
'events': [
{
'content': {
'body': 'This is an example text message',
'body': 'This is a second text example message',
'msgtype': 'm.text',
'format': 'org.matrix.custom.html',
'formatted_body': '<b>This is an example text message</b>'
'formatted_body':
'<b>This is a second text example message</b>'
},
'type': 'm.room.message',
'event_id': '143273582443PhrSn:example.org',
'event_id': '143274597446PhrSn:example.org',
'room_id': '!5345234234:example.com',
'sender': '@example:example.org',
'origin_server_ts': 1432735824654,
'unsigned': {'age': 1234}
},
{
'content': {
'body': 'This is a first text example message',
'msgtype': 'm.text',
'format': 'org.matrix.custom.html',
'formatted_body':
'<b>This is a first text example message</b>'
},
'type': 'm.room.message',
'event_id': '143274597443PhrSn:example.org',
'room_id': '!5345234234:example.com',
'sender': '@example:example.org',
'origin_server_ts': 1432735824653,
'unsigned': {'age': 1234}
},
]
}
],
'prev_batch': 't_1234a'
},
'state': {
'events': [
@ -940,7 +1016,8 @@ class FakeMatrixApi extends BaseClient {
'state_key': ''
},
]
}
},
'prev_batch': 't_1234b'
},
},
}
@ -1518,6 +1595,8 @@ class FakeMatrixApi extends BaseClient {
(var req) => messagesResponseFutureEnd,
'/client/v3/rooms/!1234%3Aexample.com/messages?from=t789&dir=f&limit=30&filter=%7B%22lazy_load_members%22%3Atrue%7D':
(var req) => messagesResponseFutureEnd,
'/client/v3/rooms/!5345234234%3Aexample.com/messages?from=t_1234a&dir=b&limit=30&filter=%7B%22lazy_load_members%22%3Atrue%7D':
(var req) => archivesMessageResponse,
'/client/versions': (var req) => {
'versions': [
'v1.1',

View File

@ -0,0 +1,138 @@
/*
* Famedly Matrix SDK
* Copyright (C) 2022 Famedly GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:olm/olm.dart' as olm;
import 'package:test/test.dart';
import 'package:matrix/matrix.dart';
import 'fake_client.dart';
void main() {
group('Timeline', () {
Logs().level = Level.error;
var olmEnabled = true;
final insertList = <int>[];
late Client client;
setUp(() async {
try {
await olm.init();
olm.get_library_version();
} catch (e) {
olmEnabled = false;
Logs().w('[LibOlm] Failed to load LibOlm', e);
}
Logs().i('[LibOlm] Enabled: $olmEnabled');
client = await getClient();
client.sendMessageTimeoutSeconds = 5;
await client.abortSync();
insertList.clear();
});
tearDown(() => client.dispose().onError((e, s) {}));
test('archive room not loaded', () async {
final archiveRoom =
client.getArchiveRoomFromCache('!5345234234:example.com');
expect(archiveRoom, null);
});
test('get archive', () async {
final archive = await client.loadArchiveWithTimeline();
expect(archive.length, 2);
expect(client.rooms.length, 2);
expect(archive[0].room.id, '!5345234234:example.com');
expect(archive[0].room.membership, Membership.leave);
expect(archive[0].room.name, 'The room name');
expect(archive[0].room.lastEvent?.body,
'This is a second text example message');
expect(archive[0].room.roomAccountData.length, 1);
expect(archive[1].room.id, '!5345234235:example.com');
expect(archive[1].room.membership, Membership.leave);
expect(archive[1].room.name, 'The room name 2');
final archiveRoom =
client.getArchiveRoomFromCache('!5345234234:example.com');
expect(archiveRoom != null, true);
expect(archiveRoom!.timeline.events.length, 2);
});
test('request history', () async {
await client.loadArchiveWithTimeline();
final archiveRoom = client.getRoomById('!5345234234:example.com');
expect(archiveRoom != null, true);
final timeline = await archiveRoom!.getTimeline(onInsert: insertList.add);
expect(timeline.events.length, 2);
expect(timeline.events[0].eventId, '143274597443PhrSn:example.org');
expect(timeline.events[1].eventId, '143274597446PhrSn:example.org');
await timeline.requestHistory();
expect(timeline.events.length, 5);
expect(timeline.events[0].eventId, '143274597443PhrSn:example.org');
expect(timeline.events[1].eventId, '143274597446PhrSn:example.org');
expect(timeline.events[2].eventId, '3143273582443PhrSn:example.org');
expect(timeline.events[3].eventId, '2143273582443PhrSn:example.org');
expect(timeline.events[4].eventId, '1143273582466PhrSn:example.org');
expect(insertList.length, 3);
});
test('expect database to be empty', () async {
await client.loadArchiveWithTimeline();
final archiveRoom = client.getRoomById('!5345234234:example.com');
expect(archiveRoom != null, true);
final eventsFromStore = await client.database?.getEventList(
archiveRoom!,
start: 0,
limit: Room.defaultHistoryCount,
);
expect(eventsFromStore?.isEmpty, true);
});
test('discard room from archives when membership change', () async {
await client.loadArchiveWithTimeline();
expect(client.getArchiveRoomFromCache('!5345234235:example.com') != null,
true);
await client.handleSync(SyncUpdate(
nextBatch: 't_456',
rooms: RoomsUpdate(
invite: {'!5345234235:example.com': InvitedRoomUpdate()})));
expect(client.getArchiveRoomFromCache('!5345234235:example.com'), null);
});
test('clear archive', () async {
await client.loadArchiveWithTimeline();
client.clearArchivesFromCache();
expect(client.getArchiveRoomFromCache('!5345234234:example.com'), null);
});
test('logout', () async {
await client.logout();
});
});
}