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
+4
View File
@@ -1437,6 +1437,7 @@
E3E1E255DC8CB34BD8573E0D /* UserIndicatorControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12D3B1BCF920880CA8BBB6B /* UserIndicatorControllerProtocol.swift */; }; E3E1E255DC8CB34BD8573E0D /* UserIndicatorControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12D3B1BCF920880CA8BBB6B /* UserIndicatorControllerProtocol.swift */; };
E3EBC3BF7CE3960B41757BAA /* Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7310D8DFE01AF45F0689C3AA /* Publisher.swift */; }; E3EBC3BF7CE3960B41757BAA /* Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7310D8DFE01AF45F0689C3AA /* Publisher.swift */; };
E468CC731C3F4D678499E52F /* LAContextMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA5A62DA4B543827FF82354 /* LAContextMock.swift */; }; E468CC731C3F4D678499E52F /* LAContextMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA5A62DA4B543827FF82354 /* LAContextMock.swift */; };
E471DCA49612AEBF4DD8C8D4 /* RoomSelectionScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C69A3015DB39E3465B00B8BF /* RoomSelectionScreenViewModelTests.swift */; };
E481C8FDCB6C089963C95344 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = BC01130651CB23340B899032 /* DeviceKit */; }; E481C8FDCB6C089963C95344 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = BC01130651CB23340B899032 /* DeviceKit */; };
E49F74BD93230BDEFFE5EA51 /* RoomNotificationSettingsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D295F0081084F38DB20893 /* RoomNotificationSettingsScreenViewModelTests.swift */; }; E49F74BD93230BDEFFE5EA51 /* RoomNotificationSettingsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D295F0081084F38DB20893 /* RoomNotificationSettingsScreenViewModelTests.swift */; };
E4B07FF075C99D04D9AF792D /* AppLockSetupPINScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B410B32B72C90BF94E481F33 /* AppLockSetupPINScreenModels.swift */; }; E4B07FF075C99D04D9AF792D /* AppLockSetupPINScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B410B32B72C90BF94E481F33 /* AppLockSetupPINScreenModels.swift */; };
@@ -2948,6 +2949,7 @@
C5CB64E711E86270B722FAF7 /* LiveLocationManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationManagerTests.swift; sourceTree = "<group>"; }; C5CB64E711E86270B722FAF7 /* LiveLocationManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationManagerTests.swift; sourceTree = "<group>"; };
C5F06F2F09B2EDD067DC2174 /* NotificationSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsScreen.swift; sourceTree = "<group>"; }; C5F06F2F09B2EDD067DC2174 /* NotificationSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsScreen.swift; sourceTree = "<group>"; };
C687844F60BFF532D49A994C /* AnalyticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsTests.swift; sourceTree = "<group>"; }; C687844F60BFF532D49A994C /* AnalyticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsTests.swift; sourceTree = "<group>"; };
C69A3015DB39E3465B00B8BF /* RoomSelectionScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSelectionScreenViewModelTests.swift; sourceTree = "<group>"; };
C6A9F49B3EE59147AF2F70BB /* SeparatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorRoomTimelineItem.swift; sourceTree = "<group>"; }; C6A9F49B3EE59147AF2F70BB /* SeparatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorRoomTimelineItem.swift; sourceTree = "<group>"; };
C705E605EF57C19DBE86FFA1 /* PlaceholderAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderAvatarImage.swift; sourceTree = "<group>"; }; C705E605EF57C19DBE86FFA1 /* PlaceholderAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderAvatarImage.swift; sourceTree = "<group>"; };
C715CFE00686DACA59D836EA /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/SAS.strings; sourceTree = "<group>"; }; C715CFE00686DACA59D836EA /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/SAS.strings; sourceTree = "<group>"; };
@@ -5322,6 +5324,7 @@
B40233F2989AD49906BB310D /* RoomPollsHistoryScreenViewModelTests.swift */, B40233F2989AD49906BB310D /* RoomPollsHistoryScreenViewModelTests.swift */,
48FEFF746DB341CDB18D7AAA /* RoomRolesAndPermissionsScreenViewModelTests.swift */, 48FEFF746DB341CDB18D7AAA /* RoomRolesAndPermissionsScreenViewModelTests.swift */,
93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */, 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */,
C69A3015DB39E3465B00B8BF /* RoomSelectionScreenViewModelTests.swift */,
AEEAFB646E583655652C3D04 /* RoomStateEventStringBuilderTests.swift */, AEEAFB646E583655652C3D04 /* RoomStateEventStringBuilderTests.swift */,
F46E441BA50705E6CEC89FE0 /* RoomSummaryProviderTests.swift */, F46E441BA50705E6CEC89FE0 /* RoomSummaryProviderTests.swift */,
046C0D3F53B0B5EF0A1F5BEA /* RoomSummaryTests.swift */, 046C0D3F53B0B5EF0A1F5BEA /* RoomSummaryTests.swift */,
@@ -8440,6 +8443,7 @@
7B1605C6FFD4D195F264A684 /* RoomPollsHistoryScreenViewModelTests.swift in Sources */, 7B1605C6FFD4D195F264A684 /* RoomPollsHistoryScreenViewModelTests.swift in Sources */,
84C631E734FD2555B39B681C /* RoomRolesAndPermissionsScreenViewModelTests.swift in Sources */, 84C631E734FD2555B39B681C /* RoomRolesAndPermissionsScreenViewModelTests.swift in Sources */,
46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */, 46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */,
E471DCA49612AEBF4DD8C8D4 /* RoomSelectionScreenViewModelTests.swift in Sources */,
CC0D088F505F33A20DC5590F /* RoomStateEventStringBuilderTests.swift in Sources */, CC0D088F505F33A20DC5590F /* RoomStateEventStringBuilderTests.swift in Sources */,
6AB306367E56A6F6DFA0E2FF /* RoomSummaryProviderTests.swift in Sources */, 6AB306367E56A6F6DFA0E2FF /* RoomSummaryProviderTests.swift in Sources */,
15913A5B07118C1268A840E4 /* RoomSummaryTests.swift in Sources */, 15913A5B07118C1268A840E4 /* RoomSummaryTests.swift in Sources */,
@@ -43,8 +43,8 @@ extension RoomSummaryProviderMock {
setFilterClosure = { [initialRooms, roomListSubject] filter in setFilterClosure = { [initialRooms, roomListSubject] filter in
switch filter { switch filter {
case let .search(query, joinedOnly): case let .search(query):
var rooms = joinedOnly ? initialRooms.filter { $0.joinRequestType == nil } : initialRooms var rooms = initialRooms
if !query.isEmpty { if !query.isEmpty {
rooms = rooms.filter { $0.name.localizedCaseInsensitiveContains(query) } rooms = rooms.filter { $0.name.localizedCaseInsensitiveContains(query) }
@@ -73,10 +73,12 @@ extension RoomSummary {
static func mock(id: String, static func mock(id: String,
name: String, name: String,
isSpace: Bool = false, isSpace: Bool = false,
isJoined: Bool = true,
canonicalAlias: String? = nil) -> RoomSummary { canonicalAlias: String? = nil) -> RoomSummary {
RoomSummary(room: RoomSDKMock(.init()), RoomSummary(room: RoomSDKMock(.init()),
id: id, id: id,
joinRequestType: nil, joinRequestType: nil,
isJoined: isJoined,
name: name, name: name,
isDirect: false, isDirect: false,
isSpace: isSpace, isSpace: isSpace,
@@ -105,6 +107,7 @@ extension Array where Element == RoomSummary {
RoomSummary(room: RoomSDKMock(), RoomSummary(room: RoomSDKMock(),
id: "1", id: "1",
joinRequestType: nil, joinRequestType: nil,
isJoined: true,
name: "Foundation 🔭🪐🌌", name: "Foundation 🔭🪐🌌",
isDirect: false, isDirect: false,
isSpace: false, isSpace: false,
@@ -128,6 +131,7 @@ extension Array where Element == RoomSummary {
RoomSummary(room: RoomSDKMock(), RoomSummary(room: RoomSDKMock(),
id: "2", id: "2",
joinRequestType: nil, joinRequestType: nil,
isJoined: true,
name: "Foundation and Empire", name: "Foundation and Empire",
isDirect: false, isDirect: false,
isSpace: false, isSpace: false,
@@ -151,6 +155,7 @@ extension Array where Element == RoomSummary {
RoomSummary(room: RoomSDKMock(), RoomSummary(room: RoomSDKMock(),
id: "3", id: "3",
joinRequestType: nil, joinRequestType: nil,
isJoined: true,
name: "Second Foundation", name: "Second Foundation",
isDirect: false, isDirect: false,
isSpace: false, isSpace: false,
@@ -174,6 +179,7 @@ extension Array where Element == RoomSummary {
RoomSummary(room: RoomSDKMock(), RoomSummary(room: RoomSDKMock(),
id: "4", id: "4",
joinRequestType: nil, joinRequestType: nil,
isJoined: true,
name: "Foundation's Edge", name: "Foundation's Edge",
isDirect: false, isDirect: false,
isSpace: false, isSpace: false,
@@ -197,6 +203,7 @@ extension Array where Element == RoomSummary {
RoomSummary(room: RoomSDKMock(), RoomSummary(room: RoomSDKMock(),
id: "5", id: "5",
joinRequestType: nil, joinRequestType: nil,
isJoined: true,
name: "Foundation and Earth", name: "Foundation and Earth",
isDirect: true, isDirect: true,
isSpace: false, isSpace: false,
@@ -220,6 +227,7 @@ extension Array where Element == RoomSummary {
RoomSummary(room: RoomSDKMock(), RoomSummary(room: RoomSDKMock(),
id: "6", id: "6",
joinRequestType: nil, joinRequestType: nil,
isJoined: true,
name: "Prelude to Foundation", name: "Prelude to Foundation",
isDirect: true, isDirect: true,
isSpace: false, isSpace: false,
@@ -243,6 +251,7 @@ extension Array where Element == RoomSummary {
RoomSummary(room: RoomSDKMock(), RoomSummary(room: RoomSDKMock(),
id: "7", id: "7",
joinRequestType: nil, joinRequestType: nil,
isJoined: true,
name: "Tombstoned", name: "Tombstoned",
isDirect: false, isDirect: false,
isSpace: false, isSpace: false,
@@ -266,6 +275,7 @@ extension Array where Element == RoomSummary {
RoomSummary(room: RoomSDKMock(), RoomSummary(room: RoomSDKMock(),
id: "0", id: "0",
joinRequestType: nil, joinRequestType: nil,
isJoined: true,
name: "Unknown", name: "Unknown",
isDirect: false, isDirect: false,
isSpace: false, isSpace: false,
@@ -322,6 +332,7 @@ extension Array where Element == RoomSummary {
RoomSummary(room: RoomSDKMock(), RoomSummary(room: RoomSDKMock(),
id: "someAwesomeRoomId1", id: "someAwesomeRoomId1",
joinRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie), joinRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie),
isJoined: false,
name: "First room", name: "First room",
isDirect: false, isDirect: false,
isSpace: false, isSpace: false,
@@ -345,6 +356,7 @@ extension Array where Element == RoomSummary {
RoomSummary(room: RoomSDKMock(), RoomSummary(room: RoomSDKMock(),
id: "someAwesomeRoomId2", id: "someAwesomeRoomId2",
joinRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie), joinRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie),
isJoined: false,
name: "Second room", name: "Second room",
isDirect: true, isDirect: true,
isSpace: false, isSpace: false,
@@ -371,6 +383,7 @@ extension Array where Element == RoomSummary {
RoomSummary(room: RoomSDKMock(), RoomSummary(room: RoomSDKMock(),
id: "!space1:matrix.org", id: "!space1:matrix.org",
joinRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie), joinRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie),
isJoined: false,
name: "First space", name: "First space",
isDirect: false, isDirect: false,
isSpace: true, isSpace: true,
@@ -394,6 +407,7 @@ extension Array where Element == RoomSummary {
RoomSummary(room: RoomSDKMock(), RoomSummary(room: RoomSDKMock(),
id: "!space2:matrix.org", id: "!space2:matrix.org",
joinRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie), joinRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie),
isJoined: false,
name: "Second space", name: "Second space",
isDirect: false, isDirect: false,
isSpace: true, isSpace: true,
@@ -218,6 +218,7 @@ private extension HomeScreenRoom {
let summary = RoomSummary(room: RoomSDKMock(), let summary = RoomSummary(room: RoomSDKMock(),
id: "@someone:somewhere.com", id: "@someone:somewhere.com",
joinRequestType: .invite(inviter: inviter), joinRequestType: .invite(inviter: inviter),
isJoined: false,
name: "Some Guy", name: "Some Guy",
isDirect: true, isDirect: true,
isSpace: false, isSpace: false,
@@ -254,6 +255,7 @@ private extension HomeScreenRoom {
let summary = RoomSummary(room: RoomSDKMock(), let summary = RoomSummary(room: RoomSDKMock(),
id: "@someone:somewhere.com", id: "@someone:somewhere.com",
joinRequestType: .invite(inviter: inviter), joinRequestType: .invite(inviter: inviter),
isJoined: false,
name: name, name: name,
isDirect: false, isDirect: false,
isSpace: isSpace, isSpace: isSpace,
@@ -150,6 +150,7 @@ private extension HomeScreenRoom {
let summary = RoomSummary(room: RoomSDKMock(), let summary = RoomSummary(room: RoomSDKMock(),
id: "@someone:somewhere.com", id: "@someone:somewhere.com",
joinRequestType: .invite(inviter: inviter), joinRequestType: .invite(inviter: inviter),
isJoined: false,
name: "Some Guy", name: "Some Guy",
isDirect: true, isDirect: true,
isSpace: false, isSpace: false,
@@ -183,6 +184,7 @@ private extension HomeScreenRoom {
let summary = RoomSummary(room: RoomSDKMock(), let summary = RoomSummary(room: RoomSDKMock(),
id: "@someone:somewhere.com", id: "@someone:somewhere.com",
joinRequestType: .invite(inviter: inviter), joinRequestType: .invite(inviter: inviter),
isJoined: false,
name: "Awesome Room", name: "Awesome Room",
isDirect: false, isDirect: false,
isSpace: false, isSpace: false,
@@ -268,6 +268,7 @@ struct HomeScreenRoomCell_Previews: PreviewProvider, TestablePreview {
let summary = RoomSummary(room: RoomSDKMock(), let summary = RoomSummary(room: RoomSDKMock(),
id: UUID().uuidString, id: UUID().uuidString,
joinRequestType: nil, joinRequestType: nil,
isJoined: true,
name: name, name: name,
isDirect: heroes.count == 1, isDirect: heroes.count == 1,
isSpace: false, isSpace: false,
@@ -88,7 +88,7 @@ class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, Me
private func updateRooms() { private func updateRooms() {
state.rooms = roomSummaryProvider.roomListPublisher.value state.rooms = roomSummaryProvider.roomListPublisher.value
.filter { $0.id != forwardingPayload.roomID } .filter { $0.id != forwardingPayload.roomID && !$0.isTombstoned }
.map(MessageForwardingRoom.init(summary:)) .map(MessageForwardingRoom.init(summary:))
} }
@@ -99,7 +99,7 @@ class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, Me
state.suggestedRooms = roomIDs state.suggestedRooms = roomIDs
.filter { $0 != forwardingPayload.roomID } .filter { $0 != forwardingPayload.roomID }
.compactMap(clientProxy.roomSummaryForIdentifier) .compactMap(clientProxy.roomSummaryForIdentifier)
.filter { $0.joinRequestType == nil && !$0.isSpace && !$0.isTombstoned } .filter { $0.isJoined && !$0.isSpace && !$0.isTombstoned }
.prefix(Self.maxSuggestedRoomCount) .prefix(Self.maxSuggestedRoomCount)
.map(MessageForwardingRoom.init(summary:)) .map(MessageForwardingRoom.init(summary:))
} }
@@ -77,7 +77,7 @@ class RoomSelectionScreenViewModel: RoomSelectionScreenViewModelType, RoomSelect
private func updateRooms() { private func updateRooms() {
var rooms = [RoomSelectionRoom]() var rooms = [RoomSelectionRoom]()
for summary in roomSummaryProvider.roomListPublisher.value { for summary in roomSummaryProvider.roomListPublisher.value where !summary.isTombstoned {
rooms.append(.init(id: summary.id, rooms.append(.init(id: summary.id,
title: summary.name, title: summary.name,
description: summary.roomListDescription, description: summary.roomListDescription,
@@ -194,7 +194,7 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
switch mode { switch mode {
case .rooms: case .rooms:
searchedQueries[mode] = searchQuery searchedQueries[mode] = searchQuery
roomSummaryProvider.setFilter(.search(query: searchQuery, joinedOnly: true)) roomSummaryProvider.setFilter(.search(query: searchQuery))
case .messages: case .messages:
setQueryTask = Task { [weak self] in setQueryTask = Task { [weak self] in
// Debounce message queries; superseded keystrokes cancel this before it commits. // Debounce message queries; superseded keystrokes cancel this before it commits.
@@ -215,7 +215,7 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
return .query(query) return .query(query)
case .room(let roomID): case .room(let roomID):
// Drop rooms the user has since left or is only invited to, we've got nothing to show for them. // Drop rooms the user has since left or is only invited to, we've got nothing to show for them.
guard let summary = clientProxy.roomSummaryForIdentifier(roomID), summary.joinRequestType == nil else { return nil } guard let summary = clientProxy.roomSummaryForIdentifier(roomID), summary.isJoined else { return nil }
return .room(SearchScreenRoom(summary)) return .room(SearchScreenRoom(summary))
} }
} }
@@ -1582,6 +1582,7 @@ private struct ClientProxyServices {
alternateRoomSummaryProvider = RoomSummaryProvider(roomListService: roomListService, alternateRoomSummaryProvider = RoomSummaryProvider(roomListService: roomListService,
eventStringBuilder: eventStringBuilder, eventStringBuilder: eventStringBuilder,
name: "AlternateAllRooms", name: "AlternateAllRooms",
includesOnlyJoinedRooms: true,
notificationSettings: notificationSettings, notificationSettings: notificationSettings,
userSettings: userSettings) userSettings: userSettings)
@@ -149,7 +149,7 @@ protocol ClientProxyProtocol: AnyObject {
/// Used for listing rooms that shouldn't be affected by the main `roomSummaryProvider` filtering /// Used for listing rooms that shouldn't be affected by the main `roomSummaryProvider` filtering
/// But can still be filtered by queries, since this may be shared across multiple views, remember to reset /// But can still be filtered by queries, since this may be shared across multiple views, remember to reset
/// The filtering state when you are done with it /// The filtering state when you are done with it. Only lists joined rooms.
var alternateRoomSummaryProvider: RoomSummaryProviderProtocol { get } var alternateRoomSummaryProvider: RoomSummaryProviderProtocol { get }
/// Used for listing rooms, can't be filtered nor its state observed /// Used for listing rooms, can't be filtered nor its state observed
@@ -30,6 +30,7 @@ nonisolated struct RoomSummary {
let id: String let id: String
let joinRequestType: JoinRequestType? let joinRequestType: JoinRequestType?
let isJoined: Bool
let name: String let name: String
let isDirect: Bool let isDirect: Bool
@@ -118,6 +119,7 @@ nonisolated extension RoomSummary {
RoomSummary(room: room, RoomSummary(room: room,
id: room.id(), id: room.id(),
joinRequestType: nil, joinRequestType: nil,
isJoined: false,
name: room.id(), name: room.id(),
isDirect: false, isDirect: false,
isSpace: false, isSpace: false,
@@ -165,6 +167,7 @@ nonisolated extension RoomSummary {
activeCallIntent = nil activeCallIntent = nil
joinRequestType = nil joinRequestType = nil
isJoined = true
isMarkedUnread = false isMarkedUnread = false
isFavourite = false isFavourite = false
isTombstoned = false isTombstoned = false
@@ -17,6 +17,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
private let shouldUpdateVisibleRange: Bool private let shouldUpdateVisibleRange: Bool
private let notificationSettings: NotificationSettingsProxyProtocol private let notificationSettings: NotificationSettingsProxyProtocol
private let userSettings: UserSettings private let userSettings: UserSettings
private let baseFilter: [RoomListEntriesDynamicFilterKind]
private let roomListPageSize: UInt32 private let roomListPageSize: UInt32
/// Remember how many rooms we had on the previous requests so we can deduplicate /// Remember how many rooms we had on the previous requests so we can deduplicate
@@ -62,10 +63,12 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
/// - shouldUpdateVisibleRange: whether this summary provider should forward visible ranges /// - shouldUpdateVisibleRange: whether this summary provider should forward visible ranges
/// to the room list service through the `applyInput(input: .viewport(ranges` api. Only useful for /// to the room list service through the `applyInput(input: .viewport(ranges` api. Only useful for
/// lists that need to update the visible range on Sliding Sync /// lists that need to update the visible range on Sliding Sync
/// - includesOnlyJoinedRooms: whether to leave out invites and other rooms the user hasn't joined, whatever the filter
init(roomListService: RoomListServiceProtocol, init(roomListService: RoomListServiceProtocol,
eventStringBuilder: RoomEventStringBuilder, eventStringBuilder: RoomEventStringBuilder,
name: String, name: String,
shouldUpdateVisibleRange: Bool = false, shouldUpdateVisibleRange: Bool = false,
includesOnlyJoinedRooms: Bool = false,
roomListPageSize: UInt32 = 100, roomListPageSize: UInt32 = 100,
notificationSettings: NotificationSettingsProxyProtocol, notificationSettings: NotificationSettingsProxyProtocol,
userSettings: UserSettings) { userSettings: UserSettings) {
@@ -77,6 +80,10 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
self.userSettings = userSettings self.userSettings = userSettings
self.roomListPageSize = roomListPageSize self.roomListPageSize = roomListPageSize
baseFilter = [.any(filters: [.all(filters: [.nonSpace, .nonLeft]),
.all(filters: [.space, .invite])]),
.deduplicateVersions] + (includesOnlyJoinedRooms ? [.joined] : [])
let (diffsStream, diffsContinuation) = AsyncStream<[RoomListEntriesUpdate]>.makeStream() let (diffsStream, diffsContinuation) = AsyncStream<[RoomListEntriesUpdate]>.makeStream()
self.diffsContinuation = diffsContinuation self.diffsContinuation = diffsContinuation
@@ -120,8 +127,9 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
}) })
// Forces the listener above to be called with the current state // Forces the listener above to be called with the current state
let filter = currentFilter ?? .all(filters: [])
currentFilter = nil currentFilter = nil
setFilter(.all(filters: [])) setFilter(filter)
let stateUpdatesSubscriptionResult = try roomList.loadingState(listener: SDKListener { [loadingStateContinuation] state in let stateUpdatesSubscriptionResult = try roomList.loadingState(listener: SDKListener { [loadingStateContinuation] state in
loadingStateContinuation.yield(state) loadingStateContinuation.yield(state)
@@ -146,15 +154,11 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
currentFilter = filter currentFilter = filter
let baseFilter: [RoomListEntriesDynamicFilterKind] = [.any(filters: [.all(filters: [.nonSpace, .nonLeft]),
.all(filters: [.space, .invite])]),
.deduplicateVersions]
switch filter { switch filter {
case .excludeAll: case .excludeAll:
_ = listUpdatesSubscriptionResult?.controller().setFilter(kind: .none) _ = listUpdatesSubscriptionResult?.controller().setFilter(kind: .none)
case let .search(query, joinedOnly): case let .search(query):
let filters = nameFilter(for: query) + baseFilter + (joinedOnly ? [.joined] : []) let filters = nameFilter(for: query) + baseFilter
_ = listUpdatesSubscriptionResult?.controller().setFilter(kind: .all(filters: filters)) _ = listUpdatesSubscriptionResult?.controller().setFilter(kind: .all(filters: filters))
case .rooms(let roomIDs, let filters): case .rooms(let roomIDs, let filters):
var rustFilters = filters.map(\.rustFilter) + baseFilter var rustFilters = filters.map(\.rustFilter) + baseFilter
@@ -372,6 +376,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
return RoomSummary(room: room, return RoomSummary(room: room,
id: roomInfo.id, id: roomInfo.id,
joinRequestType: joinRequestType, joinRequestType: joinRequestType,
isJoined: roomInfo.membership == .joined,
name: roomInfo.displayName ?? roomInfo.id, name: roomInfo.displayName ?? roomInfo.id,
isDirect: roomInfo.isDirect, isDirect: roomInfo.isDirect,
isSpace: roomInfo.isSpace, isSpace: roomInfo.isSpace,
@@ -36,8 +36,8 @@ enum RoomSummaryProviderState {
enum RoomSummaryProviderFilter: Equatable { enum RoomSummaryProviderFilter: Equatable {
/// Filters out everything /// Filters out everything
case excludeAll case excludeAll
/// Includes only the items that satisfy the predicate logic, optionally restricted to joined rooms /// Includes only the items that satisfy the predicate logic
case search(query: String, joinedOnly: Bool = false) case search(query: String)
/// Includes only what satisfies the filters used /// Includes only what satisfies the filters used
case all(filters: Set<RoomListFilter>) case all(filters: Set<RoomListFilter>)
/// Include only rooms from the given that satisfy the given filters /// Include only rooms from the given that satisfy the given filters
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:175fc36dbb3f8b53610b87a06fb8f69d9a7c03416699a732678dc719ea7227e0 oid sha256:3d518a38be7144846c00dcf2f1fa8c610c9d081d2ae4f1f3916268a34b4bd384
size 170430 size 166840
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b1a433bd71d9eae827259513c29d4765202c38aac1a97c4e6e0527ca624779ac oid sha256:6353e5a7813a4eb3a58286d31c45923fdf3b53ebf2dae152c096735ad00bdf1c
size 174737 size 171136
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:143c4962bc624c86d81996a122894b18b76acbd587942942ca736f3ac5213c5d oid sha256:4e255a55f696f81294ea7706f61f7fe6377ab3b46a8ae8b078e5b10e65f23eae
size 118839 size 115043
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:78e40d11b882b09e271b345bbbe941216d15629a501191382ed5047e0f227c54 oid sha256:cbc6fe61ccf86ee6e8c23d282e008f533ccd2aef40935bd0e2cc4105bb36aba4
size 119142 size 115346
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4ace5cc2b095335d929c4209d96aa07bdd251ca8195eaf0e99949c978edca743 oid sha256:b650e360efd5fcb17b5756b5eb582bb6b9a5e25b46d4247ed87f652b1befd972
size 160670 size 156916
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6b7ee1aeecfabf71746ca20c3e1211659c6ec0acb2c1a434c5ce39e9bac62295 oid sha256:bc1633ed6b191833ff4df4835e5298b4460efc38663e253517b82b0a64aac746
size 162114 size 158360
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:065d06fb64af86ad92bdae8ed3f9df7adc0b00a9ac574668a318281c899d3f2c oid sha256:6589ac4a2211cf44fcb0c83c131299528d534aee9d5ccb291025621d962a4e48
size 111147 size 107113
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:89f6c4e043bf495a143c26649bd485e638643dbe5750f662d7c7dbbff37fb640 oid sha256:562a960d20d2071d90e3bf618ae624f9bc73fd06e2f0cdf26ca9bd1b32516483
size 110563 size 106529
@@ -25,6 +25,7 @@ struct HomeScreenRoomTests {
roomSummary = RoomSummary(room: RoomSDKMock(), roomSummary = RoomSummary(room: RoomSDKMock(),
id: "Test room", id: "Test room",
joinRequestType: nil, joinRequestType: nil,
isJoined: true,
name: "Test room", name: "Test room",
isDirect: false, isDirect: false,
isSpace: false, isSpace: false,
+1
View File
@@ -73,6 +73,7 @@ final class LoggingTests {
let roomSummary = RoomSummary(room: RoomSDKMock(), let roomSummary = RoomSummary(room: RoomSDKMock(),
id: "myroomid", id: "myroomid",
joinRequestType: nil, joinRequestType: nil,
isJoined: true,
name: roomName, name: roomName,
isDirect: true, isDirect: true,
isSpace: false, isSpace: false,
@@ -35,6 +35,7 @@ struct MessageForwardingScreenViewModelTests {
@Test @Test
func initialState() { 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 == 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 @Test
@@ -50,7 +51,7 @@ struct MessageForwardingScreenViewModelTests {
@Test @Test
mutating func suggestionsOnlyIncludeRoomsThatCanBeForwardedTo() async throws { 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 context = viewModel.context
let deferred = deferFulfillment(context.$viewState) { !$0.suggestedRooms.isEmpty } let deferred = deferFulfillment(context.$viewState) { !$0.suggestedRooms.isEmpty }
@@ -289,7 +290,8 @@ struct MessageForwardingScreenViewModelTests {
let clientProxy = ClientProxyMock(.init()) let clientProxy = ClientProxyMock(.init())
clientProxy.roomForIdentifierClosure = { .joined(JoinedRoomProxyMock(.init(id: $0))) } clientProxy.roomForIdentifierClosure = { .joined(JoinedRoomProxyMock(.init(id: $0))) }
clientProxy.recentlyVisitedRoomIDsReturnValue = .success(recentlyVisitedRoomIDs) 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 } } clientProxy.roomSummaryForIdentifierClosure = { roomID in knownRooms.first { $0.id == roomID } }
return MessageForwardingScreenViewModel(forwardingPayload: forwardingPayload, 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)) #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 @Test
func lowPriorityRustFilters() async { func lowPriorityRustFilters() async {
// Given a new room provider with the low priority filter enabled. // Given a new room provider with the low priority filter enabled.
@@ -89,7 +121,7 @@ final class RoomSummaryProviderTests {
// MARK: - Helpers // MARK: - Helpers
private func setup(isLowPriorityFilterEnabled: Bool = false) { private func setup(isLowPriorityFilterEnabled: Bool = false, includesOnlyJoinedRooms: Bool = false, setsRoomList: Bool = true) {
userSettings = UserSettings.volatile() userSettings = UserSettings.volatile()
userSettings.lowPriorityFilterEnabled = isLowPriorityFilterEnabled userSettings.lowPriorityFilterEnabled = isLowPriorityFilterEnabled
@@ -103,6 +135,7 @@ final class RoomSummaryProviderTests {
roomSummaryProvider = RoomSummaryProvider(roomListService: RoomListServiceSDKMock(), roomSummaryProvider = RoomSummaryProvider(roomListService: RoomListServiceSDKMock(),
eventStringBuilder: eventStringBuilder, eventStringBuilder: eventStringBuilder,
name: "Test", name: "Test",
includesOnlyJoinedRooms: includesOnlyJoinedRooms,
notificationSettings: NotificationSettingsProxyMock(with: .init()), notificationSettings: NotificationSettingsProxyMock(with: .init()),
userSettings: userSettings) userSettings: userSettings)
@@ -113,6 +146,9 @@ final class RoomSummaryProviderTests {
roomList = RoomListSDKMock() roomList = RoomListSDKMock()
roomList.entriesWithDynamicAdaptersPageSizeListenerReturnValue = dynamicAdaptersResult roomList.entriesWithDynamicAdaptersPageSizeListenerReturnValue = dynamicAdaptersResult
roomList.loadingStateListenerReturnValue = .some(.init(state: .notLoaded, stateStream: TaskHandleSDKMock())) 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(), RoomSummary(room: RoomSDKMock(),
id: roomDetails.id, id: roomDetails.id,
joinRequestType: nil, joinRequestType: nil,
isJoined: true,
name: roomDetails.name, name: roomDetails.name,
isDirect: isDirect, isDirect: isDirect,
isSpace: isSpace, isSpace: isSpace,
@@ -73,25 +73,6 @@ struct SearchScreenViewModelTests {
#expect(!searchService.setQueryCalled) #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 @Test
func messageSearch() async throws { func messageSearch() async throws {
let deferred = deferFulfillment(setQuerySubject) { $0 == "Foundation" } let deferred = deferFulfillment(setQuerySubject) { $0 == "Foundation" }
@@ -194,6 +175,17 @@ struct SearchScreenViewModelTests {
#expect(context.viewState.breadcrumbs.map(\.id) == ["query-Second", "room-2"]) #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. /// The room list is empty when the app launches, so the rooms need resolving again once it has loaded.
@Test @Test
func roomBreadcrumbsAreDisplayedAfterTheRoomListLoads() async throws { func roomBreadcrumbsAreDisplayedAfterTheRoomListLoads() async throws {