Suggest recently visited rooms when forwarding messages (#6251)

* Suggest recently visited rooms when forwarding messages

The Forward to screen now shows up to 5 recently visited rooms in a
Suggestions section, above a Chats section that lists every room. The
suggestions are never searched: while searching, only the Chats section
is shown and filtered. The source room, invites, spaces and upgraded
rooms are never suggested, and the section is hidden when there is
nothing to suggest.

The suggestions are resolved from the room summaries rather than room
proxies, so they show up without holding back the list, and they are
re-resolved as the room list loads after a cold start. ClientProxy now
exposes the recently visited room IDs for this.

The recently visited rooms filter is now async, so callers choose its
isolation instead of asserting it at runtime.

* Apply review suggestions to the forwarding suggestions

Rooms:
- Each room is listed once. Suggested rooms are left out of Chats unless
  searching, where Chats lists every result. This also removes the
  duplicated row whose selection changed without the tapped row's
  animation.
- The Chats header is hidden when the section has no rooms.

Suggestions:
- Resolve them once when the screen opens instead of on every room list
  update.
This commit is contained in:
Mauro
2026-10-02 12:04:07 +02:00
committed by GitHub
parent e67a4c388f
commit f91b7441e7
25 changed files with 287 additions and 68 deletions
@@ -37,6 +37,109 @@ struct MessageForwardingScreenViewModelTests {
#expect(!context.viewState.rooms.contains { $0.id == forwardingPayload.roomID }, "The source room ID shouldn't be shown")
}
@Test
mutating func suggestionsShowRecentlyVisitedRooms() async throws {
viewModel = makeViewModel(recentlyVisitedRoomIDs: ["3", "5", "2"])
context = viewModel.context
let deferred = deferFulfillment(context.$viewState) { !$0.suggestedRooms.isEmpty }
try await deferred.fulfill()
#expect(context.viewState.suggestedRooms.map(\.id) == ["3", "5", "2"])
}
@Test
mutating func suggestionsOnlyIncludeRoomsThatCanBeForwardedTo() async throws {
viewModel = makeViewModel(recentlyVisitedRoomIDs: [forwardingPayload.roomID, "space", "7", "someAwesomeRoomId1", "!unknown:matrix.org", "2"])
context = viewModel.context
let deferred = deferFulfillment(context.$viewState) { !$0.suggestedRooms.isEmpty }
try await deferred.fulfill()
#expect(context.viewState.suggestedRooms.map(\.id) == ["2"])
}
@Test
mutating func suggestionsAreLimitedToFiveRooms() async throws {
viewModel = makeViewModel(recentlyVisitedRoomIDs: [forwardingPayload.roomID, "7", "someAwesomeRoomId1", "2", "3", "4", "5", "6", "0"])
context = viewModel.context
let deferred = deferFulfillment(context.$viewState) { !$0.suggestedRooms.isEmpty }
try await deferred.fulfill()
#expect(context.viewState.suggestedRooms.map(\.id) == ["2", "3", "4", "5", "6"])
}
@Test
mutating func suggestedRoomsAreNotListedInChats() async throws {
viewModel = makeViewModel(recentlyVisitedRoomIDs: ["2", "5"])
context = viewModel.context
let deferred = deferFulfillment(context.$viewState) { !$0.suggestedRooms.isEmpty }
try await deferred.fulfill()
let chatIDs = context.viewState.chats.map(\.id)
#expect(!chatIDs.contains("2") && !chatIDs.contains("5"))
#expect(chatIDs.contains("3"))
}
@Test
mutating func searchingListsSuggestedRoomsInChats() async throws {
viewModel = makeViewModel(recentlyVisitedRoomIDs: ["2"])
context = viewModel.context
let deferredSuggestions = deferFulfillment(context.$viewState) { !$0.suggestedRooms.isEmpty }
try await deferredSuggestions.fulfill()
let deferredSearch = deferFulfillment(context.$viewState) { $0.rooms.count == 1 }
context.searchQuery = "Empire"
try await deferredSearch.fulfill()
#expect(context.viewState.chats.map(\.id) == ["2"])
}
@Test
mutating func searchingHidesTheSuggestions() async throws {
viewModel = makeViewModel(recentlyVisitedRoomIDs: ["2"])
context = viewModel.context
let deferredSuggestions = deferFulfillment(context.$viewState) { !$0.suggestedRooms.isEmpty }
try await deferredSuggestions.fulfill()
#expect(context.viewState.showsSuggestions)
let deferredSearch = deferFulfillment(context.$viewState) { $0.rooms.count == 1 }
context.searchQuery = "Second"
try await deferredSearch.fulfill()
#expect(!context.viewState.showsSuggestions)
#expect(context.viewState.suggestedRooms.map(\.id) == ["2"], "The suggestions shouldn't be searched")
context.searchQuery = ""
#expect(context.viewState.showsSuggestions)
}
@Test
mutating func noSuggestionsAreShownWhenNoRecentRoomCanBeSuggested() async throws {
viewModel = makeViewModel(recentlyVisitedRoomIDs: [forwardingPayload.roomID, "7"])
context = viewModel.context
let deferred = deferFailure(context.$viewState.map(\.showsSuggestions), timeout: .seconds(1)) { $0 }
try await deferred.fulfill()
}
@Test
mutating func noSuggestionsAreShownWhenRecentRoomsFailToLoad() async throws {
let clientProxy = ClientProxyMock(.init())
clientProxy.recentlyVisitedRoomIDsReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
viewModel = MessageForwardingScreenViewModel(forwardingPayload: forwardingPayload,
userSession: UserSessionMock(.init(clientProxy: clientProxy)),
roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
userIndicatorController: UserIndicatorControllerMock())
context = viewModel.context
let deferred = deferFailure(context.$viewState.map(\.showsSuggestions), timeout: .seconds(1)) { $0 }
try await deferred.fulfill()
}
@Test
mutating func roomSelection() {
context.send(viewAction: .selectRoom(roomID: "2"))
@@ -181,9 +284,13 @@ struct MessageForwardingScreenViewModelTests {
// MARK: - Helpers
private func makeViewModel(userIndicatorController: UserIndicatorControllerProtocol) -> MessageForwardingScreenViewModelProtocol {
private func makeViewModel(recentlyVisitedRoomIDs: [String] = [],
userIndicatorController: UserIndicatorControllerProtocol = UserIndicatorControllerMock()) -> MessageForwardingScreenViewModelProtocol {
let clientProxy = ClientProxyMock(.init())
clientProxy.roomForIdentifierClosure = { .joined(JoinedRoomProxyMock(.init(id: $0))) }
clientProxy.recentlyVisitedRoomIDsReturnValue = .success(recentlyVisitedRoomIDs)
let knownRooms: [RoomSummary] = .mockRooms + .mockInvites + [.mock(id: "space", name: "Space", isSpace: true)]
clientProxy.roomSummaryForIdentifierClosure = { roomID in knownRooms.first { $0.id == roomID } }
return MessageForwardingScreenViewModel(forwardingPayload: forwardingPayload,
userSession: UserSessionMock(.init(clientProxy: clientProxy)),