Only offer joined rooms in the forward and share pickers (#6255)

* Initial plan

* Offer only joined rooms in destination pickers

Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com>

* Clean whitespace in share picker test

Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com>

* Restrict the shared picker room list to joined rooms

The alternate room summary provider, which backs the forward, share and
Add rooms pickers and global search, now always includes the SDK's
joined filter in its base filter. This replaces the per-call joinedOnly
flags, so Add rooms no longer offers invited rooms or spaces and no
screen can forget the flag.

A filter set before the provider has its room list is now applied once
the list is set, instead of being reset to the default. The share picker
hit this on a cold launch from the share extension.

The forward and share pickers also hide upgraded rooms, matching the
Suggestions section. The SDK has no filter for these.

Also registers the share picker's test file in the project, fixes the
blank-line indentation SwiftFormat flagged, and re-records the picker
snapshots without the upgraded room.

* Check room membership for forwarding suggestions and search breadcrumbs

RoomSummary now has isJoined, built from the room's membership. The
forwarding suggestions and the search breadcrumbs used a missing
joinRequestType to mean joined, which is also the case for rooms that
have been left or banned from. It only worked because the room list's
base filter already drops those.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com>
Co-authored-by: Mauro Romito <mauro.romito@element.io>
This commit is contained in:
Copilot
2026-10-02 12:44:33 +00:00
committed by GitHub
parent 4fe58c2d0a
commit c41a38159b
28 changed files with 141 additions and 56 deletions
@@ -25,6 +25,7 @@ struct HomeScreenRoomTests {
roomSummary = RoomSummary(room: RoomSDKMock(),
id: "Test room",
joinRequestType: nil,
isJoined: true,
name: "Test room",
isDirect: false,
isSpace: false,
+1
View File
@@ -73,6 +73,7 @@ final class LoggingTests {
let roomSummary = RoomSummary(room: RoomSDKMock(),
id: "myroomid",
joinRequestType: nil,
isJoined: true,
name: roomName,
isDirect: true,
isSpace: false,
@@ -35,6 +35,7 @@ struct MessageForwardingScreenViewModelTests {
@Test
func initialState() {
#expect(!context.viewState.rooms.contains { $0.id == forwardingPayload.roomID }, "The source room ID shouldn't be shown")
#expect(!context.viewState.rooms.contains { $0.id == "7" }, "Upgraded rooms shouldn't be shown")
}
@Test
@@ -50,7 +51,7 @@ struct MessageForwardingScreenViewModelTests {
@Test
mutating func suggestionsOnlyIncludeRoomsThatCanBeForwardedTo() async throws {
viewModel = makeViewModel(recentlyVisitedRoomIDs: [forwardingPayload.roomID, "space", "7", "someAwesomeRoomId1", "!unknown:matrix.org", "2"])
viewModel = makeViewModel(recentlyVisitedRoomIDs: [forwardingPayload.roomID, "space", "7", "someAwesomeRoomId1", "left", "!unknown:matrix.org", "2"])
context = viewModel.context
let deferred = deferFulfillment(context.$viewState) { !$0.suggestedRooms.isEmpty }
@@ -289,7 +290,8 @@ struct MessageForwardingScreenViewModelTests {
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)]
let knownRooms: [RoomSummary] = .mockRooms + .mockInvites + [.mock(id: "space", name: "Space", isSpace: true),
.mock(id: "left", name: "Left", isJoined: false)]
clientProxy.roomSummaryForIdentifierClosure = { roomID in knownRooms.first { $0.id == roomID } }
return MessageForwardingScreenViewModel(forwardingPayload: forwardingPayload,
@@ -0,0 +1,20 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
@testable import ElementX
import Testing
struct RoomSelectionScreenViewModelTests {
@Test
func upgradedRoomsAreNotListed() {
let viewModel = RoomSelectionScreenViewModel(userSession: UserSessionMock(.init()),
roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))))
#expect(!viewModel.context.viewState.rooms.isEmpty)
#expect(!viewModel.context.viewState.rooms.contains { $0.id == "7" }, "Upgraded rooms shouldn't be shown")
}
}
@@ -41,6 +41,38 @@ final class RoomSummaryProviderTests {
#expect(dynamicEntriesController.setFilterKindReceivedInvocations.last == .all(filters: [.all(filters: [.favourite, .joined])] + baseFilters))
}
@Test
func joinedRoomsOnlyRustFilters() async {
// Given a new room provider that only includes joined rooms.
setup(includesOnlyJoinedRooms: true)
await Task.yield()
// Then the joined filter should be added to the default Rust filters.
#expect(dynamicEntriesController.setFilterKindReceivedInvocations.last == .all(filters: baseFilters + [.joined]))
// When searching.
roomSummaryProvider.setFilter(.search(query: "Foundation"))
await Task.yield()
// Then the search should be restricted to joined rooms too.
#expect(dynamicEntriesController.setFilterKindReceivedInvocations.last == .all(filters: [.normalizedMatchRoomName(pattern: "Foundation")] + baseFilters + [.joined]))
}
@Test
func filterSetBeforeTheRoomListIsKept() async {
// Given a new room provider that is filtered before it has a room list.
setup(setsRoomList: false)
roomSummaryProvider.setFilter(.search(query: "Foundation"))
// When setting its room list.
roomSummaryProvider.setRoomList(roomList)
await Task.yield()
// Then the filter should be applied instead of the default one.
#expect(dynamicEntriesController.setFilterKindCallsCount == 1)
#expect(dynamicEntriesController.setFilterKindReceivedInvocations.last == .all(filters: [.normalizedMatchRoomName(pattern: "Foundation")] + baseFilters))
}
@Test
func lowPriorityRustFilters() async {
// Given a new room provider with the low priority filter enabled.
@@ -89,7 +121,7 @@ final class RoomSummaryProviderTests {
// MARK: - Helpers
private func setup(isLowPriorityFilterEnabled: Bool = false) {
private func setup(isLowPriorityFilterEnabled: Bool = false, includesOnlyJoinedRooms: Bool = false, setsRoomList: Bool = true) {
userSettings = UserSettings.volatile()
userSettings.lowPriorityFilterEnabled = isLowPriorityFilterEnabled
@@ -103,6 +135,7 @@ final class RoomSummaryProviderTests {
roomSummaryProvider = RoomSummaryProvider(roomListService: RoomListServiceSDKMock(),
eventStringBuilder: eventStringBuilder,
name: "Test",
includesOnlyJoinedRooms: includesOnlyJoinedRooms,
notificationSettings: NotificationSettingsProxyMock(with: .init()),
userSettings: userSettings)
@@ -113,6 +146,9 @@ final class RoomSummaryProviderTests {
roomList = RoomListSDKMock()
roomList.entriesWithDynamicAdaptersPageSizeListenerReturnValue = dynamicAdaptersResult
roomList.loadingStateListenerReturnValue = .some(.init(state: .notLoaded, stateStream: TaskHandleSDKMock()))
roomSummaryProvider.setRoomList(roomList)
if setsRoomList {
roomSummaryProvider.setRoomList(roomList)
}
}
}
+1
View File
@@ -100,6 +100,7 @@ struct RoomSummaryTests {
RoomSummary(room: RoomSDKMock(),
id: roomDetails.id,
joinRequestType: nil,
isJoined: true,
name: roomDetails.name,
isDirect: isDirect,
isSpace: isSpace,
@@ -73,25 +73,6 @@ struct SearchScreenViewModelTests {
#expect(!searchService.setQueryCalled)
}
@Test
func searchingExcludesRoomsThatAreNotJoined() async throws {
let roomSummaryProvider = RoomSummaryProviderMock(.init(state: .loaded(.mockRooms + .mockInvites)))
let clientProxy = ClientProxyMock(.init())
clientProxy.searchService = searchService
let viewModel = SearchScreenViewModel(roomSummaryProvider: roomSummaryProvider,
clientProxy: clientProxy,
mediaProvider: MediaProviderMock(.init()),
userIndicatorController: userIndicatorController,
userSettings: userSettings)
let deferred = deferFulfillment(viewModel.context.observe(\.viewState.rooms)) { !$0.isEmpty }
viewModel.context.searchQuery = "room"
try await deferred.fulfill()
#expect(roomSummaryProvider.setFilterReceivedFilter == .search(query: "room", joinedOnly: true))
#expect(!viewModel.context.viewState.rooms.contains { $0.title == "First room" })
}
@Test
func messageSearch() async throws {
let deferred = deferFulfillment(setQuerySubject) { $0 == "Foundation" }
@@ -194,6 +175,17 @@ struct SearchScreenViewModelTests {
#expect(context.viewState.breadcrumbs.map(\.id) == ["query-Second", "room-2"])
}
@Test
func breadcrumbsOfRoomsThatAreNotJoinedAreHidden() async throws {
staticRoomListSubject.send([.mock(id: "2", name: "Second"), .mock(id: "left", name: "Left", isJoined: false)])
let deferred = deferFulfillment(context.observe(\.viewState.breadcrumbs)) { !$0.isEmpty }
userSettings.searchBreadcrumbs = [.room(roomID: "left"), .room(roomID: "2")]
try await deferred.fulfill()
#expect(context.viewState.breadcrumbs.map(\.id) == ["room-2"])
}
/// The room list is empty when the app launches, so the rooms need resolving again once it has loaded.
@Test
func roomBreadcrumbsAreDisplayedAfterTheRoomListLoads() async throws {