diff --git a/doc/adr/0005-support-store-members-in-hive.md b/doc/adr/0005-support-store-members-in-hive.md new file mode 100644 index 000000000..c7e16ef0e --- /dev/null +++ b/doc/adr/0005-support-store-members-in-hive.md @@ -0,0 +1,45 @@ +# 5. Support store members in hive + +Date: 2024-12-23 + +## Status + +Accepted + +- Issue: [#2165](https://github.com/linagora/twake-on-matrix/issues/2165) + +## Context + +- Not all of the members are displayed in the drop-down list +- The members only store in the memory, so when the user refreshes the page, the members are lost. + +## Decision + +- Store the members in the hive to keep the members when the user refreshes the page. +- Add some properties to request the members from the server. + +```dart + +Future> requestParticipantsFromServer({ + List membershipFilter = displayMembershipsFilter, + bool suppressWarning = false, + bool cache = true, + String? at, + Membership? membership, + Membership? notMembership, + }) {} + +``` + +- `at`: The point in time (pagination token) to return members for in the room. +This token can be obtained from a prev_batch token returned for each room by the sync API. +Defaults to the current state of the room, as determined by the server. + +- `membership`: The kind of membership to filter for. Defaults to no filtering if unspecified. +When specified alongside `not_membership`, the two parameters create an ‘or’ condition: either the membership is the same as `membership` or is not the same as `not_membership`. + +- `notMembership`: The kind of membership to exclude from the results. Defaults to no filtering if unspecified. + +## Consequences + +- The members are stored in the hive, so the members are not lost when the user refreshes the page. diff --git a/lib/src/database/database_api.dart b/lib/src/database/database_api.dart index 2d559426d..2a14b3078 100644 --- a/lib/src/database/database_api.dart +++ b/lib/src/database/database_api.dart @@ -82,6 +82,8 @@ abstract class DatabaseApi { Future> getUsers(Room room); + Future storeUsers(List users, Room room); + Future> getEventList( Room room, { int start = 0, diff --git a/lib/src/database/hive_collections_database.dart b/lib/src/database/hive_collections_database.dart index 2004fa5e3..b19cabf78 100644 --- a/lib/src/database/hive_collections_database.dart +++ b/lib/src/database/hive_collections_database.dart @@ -812,6 +812,15 @@ class HiveCollectionsDatabase extends DatabaseApi { return users; } + @override + Future storeUsers(List users, Room room) async { + for (final user in users) { + final key = TupleKey(room.id, user.id).toString(); + await _roomMembersBox.put(key, user.toJson()); + } + return; + } + @override Future insertClient( String name, diff --git a/lib/src/database/hive_database.dart b/lib/src/database/hive_database.dart index 47277f291..e7d96286a 100644 --- a/lib/src/database/hive_database.dart +++ b/lib/src/database/hive_database.dart @@ -745,6 +745,15 @@ class FamedlySdkHiveDatabase extends DatabaseApi { return users; } + @override + Future storeUsers(List users, Room room) async { + for (final user in users) { + final key = MultiKey(room.id, user.id).toString(); + await _roomMembersBox.put(key, user.toJson()); + } + return Future.value(); + } + @override Future insertClient( String name, diff --git a/lib/src/room.dart b/lib/src/room.dart index 9aefeb708..a4c5eefc4 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -1337,12 +1337,13 @@ class Room { /// List `membershipFilter` defines with what membership do you want the /// participants, default set to /// [[Membership.join, Membership.invite, Membership.knock]] - List getParticipants( - [List membershipFilter = const [ - Membership.join, - Membership.invite, - Membership.knock, - ]]) { + List getParticipants({ + List membershipFilter = const [ + Membership.join, + Membership.invite, + Membership.knock, + ], + }) { final members = states[EventTypes.RoomMember]; if (members != null) { return members.entries @@ -1363,11 +1364,15 @@ class Room { /// [[Membership.join, Membership.invite, Membership.knock]] /// Set [cache] to `false` if you do not want to cache the users in memory /// for this session which is highly recommended for large public rooms. - Future> requestParticipants( - [List membershipFilter = displayMembershipsFilter, - bool suppressWarning = false, - bool cache = true]) async { - if (!participantListComplete && partial) { + Future> requestParticipants({ + List membershipFilter = displayMembershipsFilter, + bool suppressWarning = false, + bool cache = true, + String? at, + Membership? membership, + Membership? notMembership, + }) async { + if (!participantListComplete || partial) { // we aren't fully loaded, maybe the users are in the database final users = await client.database?.getUsers(this) ?? []; for (final user in users) { @@ -1378,20 +1383,27 @@ class Room { // Do not request users from the server if we have already done it // in this session or have a complete list locally. if (_requestedParticipants || participantListComplete) { - return getParticipants(membershipFilter); + return getParticipants(membershipFilter: membershipFilter); } return await requestParticipantsFromServer( - membershipFilter, - suppressWarning, - cache, + membershipFilter: membershipFilter, + suppressWarning: suppressWarning, + cache: cache, + at: at, + membership: membership, + notMembership: notMembership, ); } - Future> requestParticipantsFromServer( - [List membershipFilter = displayMembershipsFilter, - bool suppressWarning = false, - bool cache = true]) async { + Future> requestParticipantsFromServer({ + List membershipFilter = displayMembershipsFilter, + bool suppressWarning = false, + bool cache = true, + String? at, + Membership? membership, + Membership? notMembership, + }) async { final memberCount = summary.mJoinedMemberCount; if (!suppressWarning && cache && memberCount != null && memberCount > 100) { Logs().w(''' @@ -1401,7 +1413,12 @@ class Room { '''); } - final matrixEvents = await client.getMembersByRoom(id); + final matrixEvents = await client.getMembersByRoom( + id, + at: at, + membership: membership, + notMembership: notMembership, + ); final users = matrixEvents ?.map((e) => Event.fromMatrixEvent(e, this).asUser) .toList() ?? @@ -1409,7 +1426,12 @@ class Room { if (cache) { for (final user in users) { - setState(user); // at *least* cache this in-memory + setState(user); + } + try { + await client.database?.storeUsers(users, this); + } catch (e) { + Logs().w('Room::requestParticipantsFromServer: Unable to store users in the database', e); } } diff --git a/lib/src/utils/pushrule_evaluator.dart b/lib/src/utils/pushrule_evaluator.dart index a531325fe..37f7436de 100644 --- a/lib/src/utils/pushrule_evaluator.dart +++ b/lib/src/utils/pushrule_evaluator.dart @@ -299,7 +299,11 @@ class PushruleEvaluator { } EvaluatedPushRuleAction match(Event event) { - final memberCount = event.room.getParticipants([Membership.join]).length; + final memberCount = event.room.getParticipants( + membershipFilter: [ + Membership.join, + ], + ).length; final displayName = event.room .unsafeGetUserFromMemoryOrFallback(event.room.client.userID!) .displayName; diff --git a/test/database_api_test.dart b/test/database_api_test.dart index ecdec026d..78b0430b8 100644 --- a/test/database_api_test.dart +++ b/test/database_api_test.dart @@ -230,6 +230,25 @@ void testDatabase( Room(id: '!testroom:example.com', client: Client('testclient'))); expect(users.isEmpty, true); }); + test('storeUsers', () async { + final room = + Room(id: '!testroom:example.com', client: Client('testclient')); + await database.storeUsers( + [ + User( + '@bob:example.org', + displayName: 'Bob', + avatarUrl: 'mxc://example.com', + room: room, + ) + ], + Room(id: '!testroom:example.com', client: Client('testclient')), + ); + final users = await database.getUsers( + Room(id: '!testroom:example.com', client: Client('testclient')), + ); + expect(users.single.id, '@bob:example.org'); + }); test('removeEvent', () async { await database.removeEvent('\$event:example.com', '!testroom:example.com'); final event = await database.getEventById('\$event:example.com', diff --git a/test/database_test/store_user_test.dart b/test/database_test/store_user_test.dart new file mode 100644 index 000000000..ef51ff1f9 --- /dev/null +++ b/test/database_test/store_user_test.dart @@ -0,0 +1,180 @@ +import 'package:matrix/matrix.dart'; +import 'package:test/test.dart'; + +import '../fake_database.dart'; + +void main() { + group('Store user test\n', () { + late DatabaseApi database; + late Room room; + + setUp(() async { + database = await getHiveCollectionsDatabase(null); + room = Room(id: '!testroom:example.com', client: Client('testclient')); + }); + + tearDown(() async { + await database.close(); + }); + + test( + 'Give a user\n' + 'When store user is called\n' + 'Then the user is stored in the database', + () async { + final users = [ + User( + '@bob:example.org', + displayName: 'Bob', + avatarUrl: 'mxc://example.com', + room: room, + ) + ]; + + await database.storeUsers(users, room); + + final storedUser = await database.getUsers(room); + + expect(storedUser.length, 1); + + expect( + storedUser.where((user) => user.id == '@bob:example.org').isNotEmpty, + true, + ); + }, + ); + + test( + 'Give list user (Bob_1, Bob_2, Bob_3, Bob_4)\n' + 'When store users is called\n' + 'Then all user is stored in the database', + () async { + final users = [ + User( + '@bob_1:example.org', + displayName: 'Bob', + avatarUrl: 'mxc://example.com', + room: room, + ), + User( + '@bob_2:example.org', + displayName: 'Bob_2', + avatarUrl: 'mxc://example.com', + room: room, + ), + User( + '@bob_3:example.org', + displayName: 'Bob_3', + avatarUrl: 'mxc://example.com', + room: room, + ), + User( + '@bob_4:example.org', + displayName: 'Bob_4', + avatarUrl: 'mxc://example.com', + room: room, + ), + ]; + + await database.storeUsers(users, room); + + final storedUser = await database.getUsers(room); + + expect(storedUser.length, 4); + + expect( + storedUser + .where((user) => user.id == '@bob_1:example.org') + .isNotEmpty, + true, + ); + + expect( + storedUser + .where((user) => user.id == '@bob_2:example.org') + .isNotEmpty, + true, + ); + + expect( + storedUser + .where((user) => user.id == '@bob_3:example.org') + .isNotEmpty, + true, + ); + + expect( + storedUser + .where((user) => user.id == '@bob_4:example.org') + .isNotEmpty, + true, + ); + }, + ); + + test( + 'Give list user have 2 users duplicated\n' + 'When store users is called\n' + 'Then only one user is stored in the database', + () async { + final users = [ + User( + '@bob_5:example.org', + displayName: 'Bob', + avatarUrl: 'mxc://example.com', + room: room, + ), + User( + '@bob_5:example.org', + displayName: 'Bob', + avatarUrl: 'mxc://example.com', + room: room, + ), + ]; + + await database.storeUsers(users, room); + + final storedUser = await database.getUsers(room); + + expect(storedUser.length, 1); + + expect( + storedUser.where((user) => user.id == '@bob_5:example.org').length, + 1, + ); + }, + ); + + test( + 'Give a user with id is @bob_5:example.org\n' + 'When the user existed in the database\n' + 'AND store users is called\n' + 'Then cannot store the user in the database', + () async { + final getUserInitial = await database.getUsers(room); + + expect(getUserInitial.length, 0); + + final users = [ + User( + '@bob_5:example.org', + displayName: 'Bob', + avatarUrl: 'mxc://example.com', + room: room, + ), + ]; + + await database.storeUsers(users, room); + + final storedUser = await database.getUsers(room); + + expect(storedUser.length, 1); + + expect( + storedUser.where((user) => user.id == '@bob_5:example.org').length, + 1, + ); + }, + ); + }); +}