Skip to content

Krille/refactor-archive-room-handling#2217

Open
krille-chan wants to merge 2 commits into
mainfrom
krille/refactor-archive-room-handling
Open

Krille/refactor-archive-room-handling#2217
krille-chan wants to merge 2 commits into
mainfrom
krille/refactor-archive-room-handling

Conversation

@krille-chan

@krille-chan krille-chan commented Jan 2, 2026

Copy link
Copy Markdown
Contributor

closes #2031

Test out with:

Client(
  // ...
 syncFilter: Filter(
    room: RoomFilter(
      includeLeave: true,
      state: StateFilter(lazyLoadMembers: true),
    ),
  ),
);

If you don't change the syncFilter, the behavior should be as before, just that you now have to cache the archive by yourself when requesting it.

@krille-chan
krille-chan force-pushed the krille/refactor-archive-room-handling branch from 7320e86 to 44c54ee Compare January 2, 2026 14:32
@codecov

codecov Bot commented Jan 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.00000% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.55%. Comparing base (c8afef4) to head (590dd1b).

Files with missing lines Patch % Lines
lib/src/client.dart 76.47% 8 Missing ⚠️
lib/src/database/matrix_sdk_database.dart 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2217      +/-   ##
==========================================
- Coverage   59.61%   59.55%   -0.07%     
==========================================
  Files         161      161              
  Lines       20308    20288      -20     
==========================================
- Hits        12107    12082      -25     
- Misses       8201     8206       +5     
Files with missing lines Coverage Δ
lib/src/room.dart 76.54% <ø> (+0.24%) ⬆️
lib/src/database/matrix_sdk_database.dart 90.19% <50.00%> (-1.54%) ⬇️
lib/src/client.dart 77.97% <76.47%> (-0.05%) ⬇️

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update c8afef4...590dd1b. Read the comment docs.

@krille-chan
krille-chan force-pushed the krille/refactor-archive-room-handling branch from 44c54ee to 129b959 Compare May 15, 2026 06:52
@krille-chan
krille-chan force-pushed the krille/refactor-archive-room-handling branch 2 times, most recently from 44634d1 to 84eccf8 Compare June 19, 2026 13:24
@krille-chan
krille-chan marked this pull request as ready for review June 19, 2026 13:24
Comment thread lib/src/client.dart Outdated
Comment thread lib/src/client.dart
@krille-chan
krille-chan force-pushed the krille/refactor-archive-room-handling branch 3 times, most recently from 4f2d4cf to 8b3c7bf Compare June 26, 2026 08:45
@krille-chan
krille-chan force-pushed the krille/refactor-archive-room-handling branch from 8b3c7bf to 8bb6523 Compare July 6, 2026 06:33
@cursor

cursor Bot commented Jul 6, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Breaking API removal (archivedRooms and related helpers) and changed room lookup/timeline behavior for left rooms; opt-in includeLeave changes sync and persistence semantics for apps that enable it.

Overview
Refactors how left (“archived”) rooms are handled by dropping the in-memory archive cache on Client (archivedRooms, getArchiveRoomFromCache, clearArchivesFromCache) and no longer merging archived rooms into getRoomById().

loadArchive() / loadArchiveWithTimeline() still fetch left rooms via a dedicated sync with includeLeave: true, but they build and return ArchivedRoom lists locally instead of storing them on the client. Leaving a room during normal sync no longer auto-populates that cache; apps must keep archive data themselves (e.g. from loadArchiveWithTimeline()).

When Client.syncFilter has room.includeLeave: true, left rooms can stay in client.rooms, and leave updates persist timeline, state, and room account data to the database. With the default filter (no includeLeave), behavior stays closer to before: left rooms are removed from the in-memory list and storeRoomUpdate still forgetRooms on LeftRoomUpdate.

Room.forget() no longer touches a client archive list, and getTimeline() no longer loads events from the removed archive cache for membership == leave rooms (archived timelines come from the load-archive flow or DB when includeLeave is enabled).

Reviewed by Cursor Bugbot for commit 590dd1b. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Leave removes room with includeLeave
    • Left rooms are now retained in client.rooms with leave membership and prev_batch when includeLeave is enabled.
  • ✅ Fixed: Archived getTimeline skips database
    • getTimeline now reads persisted events from the database for archived rooms as well as active rooms.
  • ✅ Fixed: Leave update skips DB membership
    • storeRoomUpdate now updates existing room rows for leave updates, including membership, prev_batch, and lastEvent.
  • ✅ Fixed: Re-invite skips membership update
    • Existing rooms now update their in-memory and persisted membership when an invite update arrives.

Create PR

Or push these changes by commenting:

@cursor push 20b0a05559
Preview (20b0a05559)
diff --git a/lib/src/client.dart b/lib/src/client.dart
--- a/lib/src/client.dart
+++ b/lib/src/client.dart
@@ -2983,12 +2983,26 @@
     }
     // If the membership is "leave" then remove the item and stop here
     else if (membership == Membership.leave) {
-      if (syncFilter.room?.includeLeave == true && !found) {
-        rooms.add(room);
+      final leftUpdate = chatUpdate as LeftRoomUpdate;
+      if (syncFilter.room?.includeLeave == true) {
+        final membershipChanged = found && room.membership != membership;
+        room.membership = membership;
+        room.prev_batch = leftUpdate.timeline?.prevBatch ?? room.prev_batch;
+        if (!found) rooms.add(room);
+        if (membershipChanged) {
+          // ignore: deprecated_member_use_from_same_package
+          room.onUpdate.add(room.id);
+        }
       } else if (found) {
         rooms.removeAt(roomIndex);
       }
     }
+    // Update membership for existing invites.
+    else if (found && chatUpdate is InvitedRoomUpdate) {
+      rooms[roomIndex].membership = membership;
+      // ignore: deprecated_member_use_from_same_package
+      rooms[roomIndex].onUpdate.add(rooms[roomIndex].id);
+    }
     // Update notification, highlight count and/or additional information
     else if (found &&
         chatUpdate is JoinedRoomUpdate &&

diff --git a/lib/src/database/matrix_sdk_database.dart b/lib/src/database/matrix_sdk_database.dart
--- a/lib/src/database/matrix_sdk_database.dart
+++ b/lib/src/database/matrix_sdk_database.dart
@@ -1305,8 +1305,10 @@
                 lastEvent: lastEvent,
               ).toJson(),
       );
-    } else if (roomUpdate is JoinedRoomUpdate) {
+    } else {
       final currentRoom = Room.fromJson(copyMap(currentRawRoom), client);
+      final joinedUpdate = roomUpdate is JoinedRoomUpdate ? roomUpdate : null;
+      final leftUpdate = roomUpdate is LeftRoomUpdate ? roomUpdate : null;
       await _roomsBox.put(
         roomId,
         Room(
@@ -1314,17 +1316,22 @@
           id: roomId,
           membership: membership,
           highlightCount:
-              roomUpdate.unreadNotifications?.highlightCount ??
+              joinedUpdate?.unreadNotifications?.highlightCount ??
               currentRoom.highlightCount,
           notificationCount:
-              roomUpdate.unreadNotifications?.notificationCount ??
+              joinedUpdate?.unreadNotifications?.notificationCount ??
               currentRoom.notificationCount,
-          prev_batch: roomUpdate.timeline?.prevBatch ?? currentRoom.prev_batch,
-          summary: RoomSummary.fromJson(
-            currentRoom.summary.toJson()
-              ..addAll(roomUpdate.summary?.toJson() ?? {}),
-          ),
-          lastEvent: lastEvent,
+          prev_batch:
+              joinedUpdate?.timeline?.prevBatch ??
+              leftUpdate?.timeline?.prevBatch ??
+              currentRoom.prev_batch,
+          summary: joinedUpdate == null
+              ? currentRoom.summary
+              : RoomSummary.fromJson(
+                  currentRoom.summary.toJson()
+                    ..addAll(joinedUpdate.summary?.toJson() ?? {}),
+                ),
+          lastEvent: lastEvent ?? currentRoom.lastEvent,
         ).toJson(),
       );
     }

diff --git a/lib/src/room.dart b/lib/src/room.dart
--- a/lib/src/room.dart
+++ b/lib/src/room.dart
@@ -1701,11 +1701,9 @@
 
     var events = <Event>[];
 
-    if (!isArchived) {
-      await client.database.transaction(() async {
-        events = await client.database.getEventList(this, limit: limit);
-      });
-    }
+    await client.database.transaction(() async {
+      events = await client.database.getEventList(this, limit: limit);
+    });
 
     var chunk = TimelineChunk(events: events);
     // Load the timeline arround eventContextId if set

diff --git a/test/fake_client.dart b/test/fake_client.dart
--- a/test/fake_client.dart
+++ b/test/fake_client.dart
@@ -20,6 +20,7 @@
 Future<Client> getClient({
   Duration sendTimelineEventTimeout = const Duration(minutes: 1),
   String? databasePath,
+  Filter? syncFilter,
 }) async {
   try {
     vodInit ??= vod.init(
@@ -37,6 +38,7 @@
     database: await getDatabase(databasePath: databasePath),
     onSoftLogout: (client) => client.refreshAccessToken(),
     sendTimelineEventTimeout: sendTimelineEventTimeout,
+    syncFilter: syncFilter,
   );
   FakeMatrixApi.client = client;
   await client.checkHomeserver(

diff --git a/test/room_archived_test.dart b/test/room_archived_test.dart
--- a/test/room_archived_test.dart
+++ b/test/room_archived_test.dart
@@ -73,6 +73,122 @@
       expect(eventsFromStore.isEmpty, true);
     });
 
+    test('includeLeave keeps left room in rooms and database', () async {
+      await client.dispose().onError((e, s) {});
+      client = await getClient(
+        syncFilter: Filter(
+          room: RoomFilter(
+            state: StateFilter(lazyLoadMembers: true),
+            includeLeave: true,
+          ),
+        ),
+      );
+      client.rooms.clear();
+      await client.database.clearCache();
+
+      const roomId = '!includeLeaveRoom:example.com';
+      await client.handleSync(
+        SyncUpdate(
+          nextBatch: 't_join',
+          rooms: RoomsUpdate(
+            join: {
+              roomId: JoinedRoomUpdate(
+                timeline: TimelineUpdate(
+                  prevBatch: 'join_batch',
+                  events: [
+                    MatrixEvent(
+                      type: EventTypes.Message,
+                      senderId: '@alice:example.com',
+                      eventId: '\$include-leave-join',
+                      originServerTs: DateTime.fromMillisecondsSinceEpoch(1),
+                      content: {'msgtype': 'm.text', 'body': 'joined'},
+                    ),
+                  ],
+                ),
+              ),
+            },
+          ),
+        ),
+      );
+
+      await client.handleSync(
+        SyncUpdate(
+          nextBatch: 't_leave',
+          rooms: RoomsUpdate(
+            leave: {
+              roomId: LeftRoomUpdate(
+                timeline: TimelineUpdate(
+                  prevBatch: 'leave_batch',
+                  events: [
+                    MatrixEvent(
+                      type: EventTypes.Message,
+                      senderId: '@alice:example.com',
+                      eventId: '\$include-leave-left',
+                      originServerTs: DateTime.fromMillisecondsSinceEpoch(2),
+                      content: {'msgtype': 'm.text', 'body': 'left'},
+                    ),
+                  ],
+                ),
+              ),
+            },
+          ),
+        ),
+      );
+
+      final room = client.getRoomById(roomId);
+      expect(room, isNotNull);
+      expect(room!.membership, Membership.leave);
+      expect(room.prev_batch, 'leave_batch');
+
+      final storedRoom = await client.database.getSingleRoom(client, roomId);
+      expect(storedRoom?.membership, Membership.leave);
+      expect(storedRoom?.prev_batch, 'leave_batch');
+      expect(storedRoom?.lastEvent?.eventId, '\$include-leave-left');
+
+      final timeline = await room.getTimeline();
+      expect(
+        timeline.events.map((event) => event.eventId),
+        contains('\$include-leave-left'),
+      );
+    });
+
+    test('reinvite updates kept left room membership', () async {
+      await client.dispose().onError((e, s) {});
+      client = await getClient(
+        syncFilter: Filter(
+          room: RoomFilter(
+            state: StateFilter(lazyLoadMembers: true),
+            includeLeave: true,
+          ),
+        ),
+      );
+      client.rooms.clear();
+      await client.database.clearCache();
+
+      const roomId = '!reinviteLeftRoom:example.com';
+      await client.handleSync(
+        SyncUpdate(
+          nextBatch: 't_join',
+          rooms: RoomsUpdate(join: {roomId: JoinedRoomUpdate()}),
+        ),
+      );
+      await client.handleSync(
+        SyncUpdate(
+          nextBatch: 't_leave',
+          rooms: RoomsUpdate(leave: {roomId: LeftRoomUpdate()}),
+        ),
+      );
+
+      await client.handleSync(
+        SyncUpdate(
+          nextBatch: 't_invite',
+          rooms: RoomsUpdate(invite: {roomId: InvitedRoomUpdate()}),
+        ),
+      );
+
+      expect(client.getRoomById(roomId)?.membership, Membership.invite);
+    });
+
     test('discard room from archives when membership change', () async {
       await client.loadArchiveWithTimeline();
       await client.handleSync(

You can send follow-ups to the cloud agent here.

Comment thread lib/src/client.dart
Comment thread lib/src/room.dart
Comment thread lib/src/database/matrix_sdk_database.dart
Comment thread lib/src/client.dart
@krille-chan
krille-chan force-pushed the krille/refactor-archive-room-handling branch from 8bb6523 to 590dd1b Compare July 13, 2026 08:50

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Archive timeline state ignored
    • Archived room timeline state events are now applied to the room without storing them before last-event calculation.
  • ✅ Fixed: Forget leaves room in list
    • A successfully forgotten room is now removed from the in-memory client room list.
  • ✅ Fixed: History mutates rooms list
    • History backfill sync handling now skips room-list membership updates for non-joined rooms.

Create PR

Or push these changes by commenting:

@cursor push 9299f8f186
Preview (9299f8f186)
diff --git a/lib/src/client.dart b/lib/src/client.dart
--- a/lib/src/client.dart
+++ b/lib/src/client.dart
@@ -1223,6 +1223,17 @@
               <String, BasicEvent>{},
         );
         entry.value.state?.forEach(room.setState);
+        final timelineStateEvents = entry.value.timeline?.events
+            ?.where((event) => event.stateKey != null)
+            .toList();
+        if (timelineStateEvents != null && timelineStateEvents.isNotEmpty) {
+          await _handleRoomEvents(
+            room,
+            timelineStateEvents,
+            EventUpdateType.timeline,
+            store: false,
+          );
+        }
         final timeline = Timeline(
           room: room,
           chunk: TimelineChunk(
@@ -2480,15 +2491,27 @@
   }
 
   /// Use this method only for testing utilities!
-  Future<void> handleSync(SyncUpdate sync, {Direction? direction}) async {
+  Future<void> handleSync(
+    SyncUpdate sync, {
+    Direction? direction,
+    bool updateRoomList = true,
+  }) async {
     // ensure we don't upload keys because someone forgot to set a key count
     sync.deviceOneTimeKeysCount ??= {
       'signed_curve25519': encryption?.olmManager.maxNumberOfOneTimeKeys ?? 100,
     };
-    await _handleSync(sync, direction: direction);
+    await _handleSync(
+      sync,
+      direction: direction,
+      updateRoomList: updateRoomList,
+    );
   }
 
-  Future<void> _handleSync(SyncUpdate sync, {Direction? direction}) async {
+  Future<void> _handleSync(
+    SyncUpdate sync, {
+    Direction? direction,
+    bool updateRoomList = true,
+  }) async {
     final syncToDevice = sync.toDevice;
     if (syncToDevice != null) {
       await _handleToDeviceEvents(syncToDevice);
@@ -2497,7 +2520,11 @@
     if (sync.rooms != null) {
       final join = sync.rooms?.join;
       if (join != null) {
-        await _handleRooms(join, direction: direction);
+        await _handleRooms(
+          join,
+          direction: direction,
+          updateRoomList: updateRoomList,
+        );
       }
       // We need to handle leave before invite. If you decline an invite and
       // then get another invite to the same room, Synapse will include the
@@ -2505,11 +2532,19 @@
       // will only be included in leave.
       final leave = sync.rooms?.leave;
       if (leave != null) {
-        await _handleRooms(leave, direction: direction);
+        await _handleRooms(
+          leave,
+          direction: direction,
+          updateRoomList: updateRoomList,
+        );
       }
       final invite = sync.rooms?.invite;
       if (invite != null) {
-        await _handleRooms(invite, direction: direction);
+        await _handleRooms(
+          invite,
+          direction: direction,
+          updateRoomList: updateRoomList,
+        );
       }
     }
     for (final newPresence in sync.presence ?? <Presence>[]) {
@@ -2645,6 +2680,7 @@
   Future<void> _handleRooms(
     Map<String, SyncRoomUpdate> rooms, {
     Direction? direction,
+    bool updateRoomList = true,
   }) async {
     var handledRooms = 0;
     for (final entry in rooms.entries) {
@@ -2657,7 +2693,11 @@
       final id = entry.key;
       final syncRoomUpdate = entry.value;
 
-      final room = await _updateRoomsByRoomUpdate(id, syncRoomUpdate);
+      final room = await _updateRoomsByRoomUpdate(
+        id,
+        syncRoomUpdate,
+        updateRoomList: updateRoomList,
+      );
 
       // Is the timeline limited? Then all previous messages should be
       // removed from the database!
@@ -2960,8 +3000,9 @@
 
   Future<Room> _updateRoomsByRoomUpdate(
     String roomId,
-    SyncRoomUpdate chatUpdate,
-  ) async {
+    SyncRoomUpdate chatUpdate, {
+    bool updateRoomList = true,
+  }) async {
     // Update the chat list item.
     // Search the room in the rooms
     final roomIndex = rooms.indexWhere((r) => r.id == roomId);
@@ -2989,13 +3030,13 @@
               : Room(id: roomId, membership: membership, client: this));
 
     // Does the chat already exist in the list rooms?
-    if (!found && membership != Membership.leave) {
+    if (updateRoomList && !found && membership != Membership.leave) {
       final position = membership == Membership.invite ? 0 : rooms.length;
       // Add the new chat to the list
       rooms.insert(position, room);
     }
     // If the membership is "leave" then remove the item and stop here
-    else if (membership == Membership.leave) {
+    else if (updateRoomList && membership == Membership.leave) {
       if (syncFilter.room?.includeLeave == true && !found) {
         rooms.add(room);
       } else if (found) {

diff --git a/lib/src/room.dart b/lib/src/room.dart
--- a/lib/src/room.dart
+++ b/lib/src/room.dart
@@ -1424,6 +1424,7 @@
   Future<void> forget() async {
     await client.database.forgetRoom(id);
     await client.forgetRoom(id);
+    client.rooms.removeWhere((room) => room.id == id);
     return;
   }
 
@@ -1548,6 +1549,7 @@
           ),
         ),
         direction: direction,
+        updateRoomList: membership == Membership.join,
       );
     }

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 590dd1b. Configure here.

Comment thread lib/src/client.dart
(event) => roomPreviewLastEvents.contains(event.type),
);

archivedRooms.add(ArchivedRoom(room: room, timeline: timeline));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Archive timeline state ignored

Medium Severity

Issue: loadArchiveWithTimeline applies only entry.value.state via setState and builds the timeline separately. Unlike the removed _storeArchivedRoom, it no longer runs timeline events through _handleRoomEvents, so state changes present only in the timeline are not applied to the room.

Fix: Also apply timeline state events to the room (e.g. reuse _handleRoomEvents with store: false) before computing lastEvent.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 590dd1b. Configure here.

Comment thread lib/src/client.dart
if (syncFilter.room?.includeLeave == true && !found) {
rooms.add(room);
} else if (found) {
rooms.removeAt(roomIndex);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

History mutates rooms list

Medium Severity

Issue: Room.requestHistory for a leave room synthesizes a LeftRoomUpdate through handleSync. With includeLeave, that update adds an ephemeral loadArchiveWithTimeline room into client.rooms when missing, or removes an existing leave entry when present.

Fix: Do not treat history backfill LeftRoomUpdates like sync membership changes when deciding to add/remove leave rooms under includeLeave.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 590dd1b. Configure here.

Comment thread lib/src/room.dart
Future<void> forget() async {
await client.database.forgetRoom(id);
await client.forgetRoom(id);
// Update archived rooms, otherwise an archived room may still be in the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forget leaves room in list

Medium Severity

Issue: forget() clears the DB and calls the forget API, but no longer updates any client-side room list. With includeLeave, left rooms live in client.rooms, so a forgotten room can remain visible until restart.

Fix: After a successful forget, remove the room from client.rooms when present.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 590dd1b. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make it possible to keep leave rooms when syncfilter includes them

1 participant