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
@@ -240,6 +240,7 @@
"common_call_started" = "Call started"; "common_call_started" = "Call started";
"common_call_you_declined" = "You declined a call"; "common_call_you_declined" = "You declined a call";
"common_chat_backup" = "Chat backup"; "common_chat_backup" = "Chat backup";
"common_chats" = "Chats";
"common_code_block" = "Code block"; "common_code_block" = "Code block";
"common_copied_to_clipboard" = "Copied to clipboard"; "common_copied_to_clipboard" = "Copied to clipboard";
"common_copyright" = "Copyright"; "common_copyright" = "Copyright";
@@ -240,6 +240,7 @@
"common_call_started" = "Call started"; "common_call_started" = "Call started";
"common_call_you_declined" = "You declined a call"; "common_call_you_declined" = "You declined a call";
"common_chat_backup" = "Chat backup"; "common_chat_backup" = "Chat backup";
"common_chats" = "Chats";
"common_code_block" = "Code block"; "common_code_block" = "Code block";
"common_copied_to_clipboard" = "Copied to clipboard"; "common_copied_to_clipboard" = "Copied to clipboard";
"common_copyright" = "Copyright"; "common_copyright" = "Copyright";
+2
View File
@@ -536,6 +536,8 @@ internal nonisolated enum L10n {
internal static var commonCallYouDeclined: String { return L10n.tr("Localizable", "common_call_you_declined") } internal static var commonCallYouDeclined: String { return L10n.tr("Localizable", "common_call_you_declined") }
/// Chat backup /// Chat backup
internal static var commonChatBackup: String { return L10n.tr("Localizable", "common_chat_backup") } internal static var commonChatBackup: String { return L10n.tr("Localizable", "common_chat_backup") }
/// Chats
internal static var commonChats: String { return L10n.tr("Localizable", "common_chats") }
/// Code block /// Code block
internal static var commonCodeBlock: String { return L10n.tr("Localizable", "common_code_block") } internal static var commonCodeBlock: String { return L10n.tr("Localizable", "common_code_block") }
/// Copied to clipboard /// Copied to clipboard
@@ -106,6 +106,7 @@ extension ClientProxyMock {
unignoreUserReturnValue = .success(()) unignoreUserReturnValue = .success(())
trackRecentlyVisitedRoomReturnValue = .success(()) trackRecentlyVisitedRoomReturnValue = .success(())
recentlyVisitedRoomIDsReturnValue = .success([])
recentlyVisitedRoomsFilterReturnValue = [] recentlyVisitedRoomsFilterReturnValue = []
recentConversationCounterpartsReturnValue = [] recentConversationCounterpartsReturnValue = []
@@ -4225,6 +4225,34 @@ nonisolated class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
return trackRecentlyVisitedRoomReturnValue return trackRecentlyVisitedRoomReturnValue
} }
} }
//MARK: - recentlyVisitedRoomIDs
private let recentlyVisitedRoomIDsCallsCountLock = NSLock()
private nonisolated(unsafe) var recentlyVisitedRoomIDsUnderlyingCallsCount = 0
var recentlyVisitedRoomIDsCallsCount: Int {
get { recentlyVisitedRoomIDsCallsCountLock.withLock { recentlyVisitedRoomIDsUnderlyingCallsCount } }
set { recentlyVisitedRoomIDsCallsCountLock.withLock { recentlyVisitedRoomIDsUnderlyingCallsCount = newValue } }
}
var recentlyVisitedRoomIDsCalled: Bool {
return recentlyVisitedRoomIDsCallsCount > 0
}
private let recentlyVisitedRoomIDsReturnValueLock = NSLock()
private nonisolated(unsafe) var recentlyVisitedRoomIDsUnderlyingReturnValue: Result<[String], ClientProxyError>!
var recentlyVisitedRoomIDsReturnValue: Result<[String], ClientProxyError>! {
get { recentlyVisitedRoomIDsReturnValueLock.withLock { recentlyVisitedRoomIDsUnderlyingReturnValue } }
set { recentlyVisitedRoomIDsReturnValueLock.withLock { recentlyVisitedRoomIDsUnderlyingReturnValue = newValue } }
}
nonisolated(unsafe) var recentlyVisitedRoomIDsClosure: (() async -> Result<[String], ClientProxyError>)?
@concurrent func recentlyVisitedRoomIDs() async -> Result<[String], ClientProxyError> {
recentlyVisitedRoomIDsCallsCountLock.withLock { recentlyVisitedRoomIDsUnderlyingCallsCount += 1 }
if let recentlyVisitedRoomIDsClosure = recentlyVisitedRoomIDsClosure {
return await recentlyVisitedRoomIDsClosure()
} else {
return recentlyVisitedRoomIDsReturnValue
}
}
//MARK: - recentlyVisitedRooms //MARK: - recentlyVisitedRooms
private let recentlyVisitedRoomsFilterCallsCountLock = NSLock() private let recentlyVisitedRoomsFilterCallsCountLock = NSLock()
@@ -4243,9 +4271,9 @@ nonisolated class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
get { recentlyVisitedRoomsFilterReturnValueLock.withLock { recentlyVisitedRoomsFilterUnderlyingReturnValue } } get { recentlyVisitedRoomsFilterReturnValueLock.withLock { recentlyVisitedRoomsFilterUnderlyingReturnValue } }
set { recentlyVisitedRoomsFilterReturnValueLock.withLock { recentlyVisitedRoomsFilterUnderlyingReturnValue = newValue } } set { recentlyVisitedRoomsFilterReturnValueLock.withLock { recentlyVisitedRoomsFilterUnderlyingReturnValue = newValue } }
} }
nonisolated(unsafe) var recentlyVisitedRoomsFilterClosure: ((@Sendable (JoinedRoomProxyProtocol) -> Bool) async -> [JoinedRoomProxyProtocol])? nonisolated(unsafe) var recentlyVisitedRoomsFilterClosure: ((@Sendable (JoinedRoomProxyProtocol) async -> Bool) async -> [JoinedRoomProxyProtocol])?
@concurrent func recentlyVisitedRooms(filter: @Sendable (JoinedRoomProxyProtocol) -> Bool) async -> [JoinedRoomProxyProtocol] { @concurrent func recentlyVisitedRooms(filter: @Sendable (JoinedRoomProxyProtocol) async -> Bool) async -> [JoinedRoomProxyProtocol] {
recentlyVisitedRoomsFilterCallsCountLock.withLock { recentlyVisitedRoomsFilterUnderlyingCallsCount += 1 } recentlyVisitedRoomsFilterCallsCountLock.withLock { recentlyVisitedRoomsFilterUnderlyingCallsCount += 1 }
if let recentlyVisitedRoomsFilterClosure = recentlyVisitedRoomsFilterClosure { if let recentlyVisitedRoomsFilterClosure = recentlyVisitedRoomsFilterClosure {
return await recentlyVisitedRoomsFilterClosure(filter) return await recentlyVisitedRoomsFilterClosure(filter)
@@ -72,13 +72,14 @@ extension RoomSummaryProviderMock {
extension RoomSummary { extension RoomSummary {
static func mock(id: String, static func mock(id: String,
name: String, name: String,
isSpace: Bool = false,
canonicalAlias: String? = nil) -> RoomSummary { canonicalAlias: String? = nil) -> RoomSummary {
RoomSummary(room: RoomSDKMock(.init()), RoomSummary(room: RoomSDKMock(.init()),
id: id, id: id,
joinRequestType: nil, joinRequestType: nil,
name: name, name: name,
isDirect: false, isDirect: false,
isSpace: false, isSpace: isSpace,
avatarURL: nil, avatarURL: nil,
heroes: [], heroes: [],
activeMembersCount: 0, activeMembersCount: 0,
@@ -15,11 +15,25 @@ enum MessageForwardingScreenViewModelAction {
} }
struct MessageForwardingScreenViewState: BindableState { struct MessageForwardingScreenViewState: BindableState {
/// The recently visited rooms, never filtered by the search query.
var suggestedRooms: [MessageForwardingRoom] = []
var rooms: [MessageForwardingRoom] = [] var rooms: [MessageForwardingRoom] = []
var selectedRoomIDs: Set<String> = [] var selectedRoomIDs: Set<String> = []
let maxRoomSelectionCount = 5 let maxRoomSelectionCount = 5
var bindings = MessageForwardingScreenViewStateBindings() var bindings = MessageForwardingScreenViewStateBindings()
var showsSuggestions: Bool {
bindings.searchQuery.isEmpty && !suggestedRooms.isEmpty
}
/// Every room appears once: the suggested ones are left out unless searching.
var chats: [MessageForwardingRoom] {
guard showsSuggestions else { return rooms }
let suggestedRoomIDs = Set(suggestedRooms.map(\.id))
return rooms.filter { !suggestedRoomIDs.contains($0.id) }
}
var isAtRoomSelectionLimit: Bool { var isAtRoomSelectionLimit: Bool {
selectedRoomIDs.count >= maxRoomSelectionCount selectedRoomIDs.count >= maxRoomSelectionCount
} }
@@ -44,6 +58,15 @@ struct MessageForwardingRoom: Identifiable, Equatable {
let avatar: RoomAvatar let avatar: RoomAvatar
} }
extension MessageForwardingRoom {
init(summary: RoomSummary) {
self.init(id: summary.id,
title: summary.name,
description: summary.roomListDescription,
avatar: summary.avatar)
}
}
struct MessageForwardingPayload: Hashable { struct MessageForwardingPayload: Hashable {
/// The source items' timeline IDs. Only necessary for a rough Hashable conformance. /// The source items' timeline IDs. Only necessary for a rough Hashable conformance.
let ids: [TimelineItemIdentifier] let ids: [TimelineItemIdentifier]
@@ -12,6 +12,8 @@ import SwiftUI
typealias MessageForwardingScreenViewModelType = StateStoreViewModel<MessageForwardingScreenViewState, MessageForwardingScreenViewAction> typealias MessageForwardingScreenViewModelType = StateStoreViewModel<MessageForwardingScreenViewState, MessageForwardingScreenViewAction>
class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, MessageForwardingScreenViewModelProtocol { class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, MessageForwardingScreenViewModelProtocol {
private static let maxSuggestedRoomCount = 5
private let forwardingPayload: MessageForwardingPayload private let forwardingPayload: MessageForwardingPayload
private let clientProxy: ClientProxyProtocol private let clientProxy: ClientProxyProtocol
private let roomSummaryProvider: RoomSummaryProviderProtocol private let roomSummaryProvider: RoomSummaryProviderProtocol
@@ -54,6 +56,8 @@ class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, Me
.store(in: &cancellables) .store(in: &cancellables)
updateRooms() updateRooms()
Task { await loadSuggestedRooms() }
} }
override func process(viewAction: MessageForwardingScreenViewAction) { override func process(viewAction: MessageForwardingScreenViewAction) {
@@ -83,20 +87,21 @@ class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, Me
// MARK: - Private // MARK: - Private
private func updateRooms() { private func updateRooms() {
var rooms = [MessageForwardingRoom]() state.rooms = roomSummaryProvider.roomListPublisher.value
.filter { $0.id != forwardingPayload.roomID }
.map(MessageForwardingRoom.init(summary:))
}
/// Built from the room summaries: building room proxies is slow and would shift the list down when the section appears.
private func loadSuggestedRooms() async {
guard case let .success(roomIDs) = await clientProxy.recentlyVisitedRoomIDs() else { return }
for summary in roomSummaryProvider.roomListPublisher.value { state.suggestedRooms = roomIDs
if summary.id == forwardingPayload.roomID { .filter { $0 != forwardingPayload.roomID }
continue .compactMap(clientProxy.roomSummaryForIdentifier)
} .filter { $0.joinRequestType == nil && !$0.isSpace && !$0.isTombstoned }
.prefix(Self.maxSuggestedRoomCount)
rooms.append(.init(id: summary.id, .map(MessageForwardingRoom.init(summary:))
title: summary.name,
description: summary.roomListDescription,
avatar: summary.avatar))
}
state.rooms = rooms
} }
/// The actual range values don't matter as long as they contain the lower /// The actual range values don't matter as long as they contain the lower
@@ -15,19 +15,26 @@ struct MessageForwardingScreen: View {
var body: some View { var body: some View {
Form { Form {
Section { if context.viewState.showsSuggestions {
ForEach(context.viewState.rooms) { room in Section {
MessageForwardingListRow(room: room, ForEach(context.viewState.suggestedRooms, content: row)
isSelected: context.viewState.selectedRoomIDs.contains(room.id), } header: {
isDisabled: context.viewState.isAtRoomSelectionLimit && !context.viewState.selectedRoomIDs.contains(room.id), Text(L10n.commonSuggestions)
context: context) .compoundListSectionHeader()
} }
}
Section {
ForEach(context.viewState.chats, content: row)
// Replace these with ScrollView's `scrollPosition` when dropping iOS 16. // Replace these with ScrollView's `scrollPosition` when dropping iOS 16.
} header: { } header: {
emptyRectangle if !context.viewState.chats.isEmpty {
.onAppear { Text(L10n.commonChats)
context.send(viewAction: .reachedTop) .compoundListSectionHeader()
} .onAppear {
context.send(viewAction: .reachedTop)
}
}
} footer: { } footer: {
emptyRectangle emptyRectangle
.onAppear { .onAppear {
@@ -56,6 +63,15 @@ struct MessageForwardingScreen: View {
.disableAutocorrection(true) .disableAutocorrection(true)
} }
private func row(for room: MessageForwardingRoom) -> some View {
let isSelected = context.viewState.selectedRoomIDs.contains(room.id)
return MessageForwardingListRow(room: room,
isSelected: isSelected,
isDisabled: context.viewState.isAtRoomSelectionLimit && !isSelected,
context: context)
}
/// The greedy size of Rectangle can create an issue with the navigation bar when the search is highlighted, so is best to use a fixed frame instead of hidden() or EmptyView() /// The greedy size of Rectangle can create an issue with the navigation bar when the search is highlighted, so is best to use a fixed frame instead of hidden() or EmptyView()
private var emptyRectangle: some View { private var emptyRectangle: some View {
Rectangle() Rectangle()
@@ -97,17 +113,40 @@ private struct MessageForwardingListRow: View {
// MARK: - Previews // MARK: - Previews
struct MessageForwardingScreen_Previews: PreviewProvider, TestablePreview { struct MessageForwardingScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel()
static let searchingViewModel = makeViewModel(searchQuery: "Foundation")
static var previews: some View { static var previews: some View {
let summaryProvider = RoomSummaryProviderMock(.init(state: .loaded(.mockRooms)))
let viewModel = MessageForwardingScreenViewModel(forwardingPayload: .init(ids: [.randomEvent],
roomID: "",
contents: [RoomMessageEventContentWithoutRelationSDKMock()]),
userSession: UserSessionMock(.init()),
roomSummaryProvider: summaryProvider,
userIndicatorController: UserIndicatorControllerMock())
ElementNavigationStack { ElementNavigationStack {
MessageForwardingScreen(context: viewModel.context) MessageForwardingScreen(context: viewModel.context)
} }
.previewDisplayName("Suggestions")
.snapshotPreferences(expect: viewModel.context.$viewState.map(\.showsSuggestions))
ElementNavigationStack {
MessageForwardingScreen(context: searchingViewModel.context)
}
.previewDisplayName("Searching")
.snapshotPreferences(expect: searchingViewModel.context.$viewState.map { !$0.suggestedRooms.isEmpty && !$0.showsSuggestions },
precision: 0.999) // The search field's clear button renders inconsistently in snapshots.
}
static func makeViewModel(searchQuery: String? = nil) -> MessageForwardingScreenViewModel {
let clientProxy = ClientProxyMock(.init())
clientProxy.recentlyVisitedRoomIDsReturnValue = .success(["2", "5", "3"])
clientProxy.roomSummaryForIdentifierClosure = { roomID in [RoomSummary].mockRooms.first { $0.id == roomID } }
let viewModel = MessageForwardingScreenViewModel(forwardingPayload: .init(ids: [.randomEvent],
roomID: "",
contents: [RoomMessageEventContentWithoutRelationSDKMock()]),
userSession: UserSessionMock(.init(clientProxy: clientProxy)),
roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
userIndicatorController: UserIndicatorControllerMock())
if let searchQuery {
viewModel.context.searchQuery = searchQuery
}
return viewModel
} }
} }
@@ -48,14 +48,11 @@ class SpaceAddRoomsScreenViewModel: SpaceAddRoomsScreenViewModelType, SpaceAddRo
Task { Task {
let existingRooms = spaceRoomListProxy.spaceRoomsPublisher.value let existingRooms = spaceRoomListProxy.spaceRoomsPublisher.value
suggestedRooms = await userSession.clientProxy suggestedRooms = await userSession.clientProxy
.recentlyVisitedRooms { roomProxy in .recentlyVisitedRooms { @MainActor roomProxy in
// The filter is Sendable for the mock's sake but the client proxy always calls it on the main actor. !roomProxy.infoPublisher.value.isDirect
MainActor.assumeIsolated { && !roomProxy.infoPublisher.value.isSpace
!roomProxy.infoPublisher.value.isDirect && roomProxy.infoPublisher.value.membership == .joined
&& !roomProxy.infoPublisher.value.isSpace && !existingRooms.contains { $0.id == roomProxy.id }
&& roomProxy.infoPublisher.value.membership == .joined
&& !existingRooms.contains { $0.id == roomProxy.id }
}
} }
.map { .init(roomProxy: $0) } .map { .init(roomProxy: $0) }
@@ -981,7 +981,17 @@ class ClientProxy: ClientProxyProtocol {
} }
} }
func recentlyVisitedRooms(filter: @Sendable (JoinedRoomProxyProtocol) -> Bool) async -> [JoinedRoomProxyProtocol] { func recentlyVisitedRoomIDs() async -> Result<[String], ClientProxyError> {
do {
let result = try await client.getRecentlyVisitedRooms()
return .success(result)
} catch {
MXLog.error("Failed retrieving recently visited rooms with error: \(error)")
return .failure(.sdkError(error))
}
}
func recentlyVisitedRooms(filter: @Sendable (JoinedRoomProxyProtocol) async -> Bool) async -> [JoinedRoomProxyProtocol] {
let maxResultsToReturn = 5 let maxResultsToReturn = 5
guard case let .success(roomIdentifiers) = await recentlyVisitedRoomIDs() else { guard case let .success(roomIdentifiers) = await recentlyVisitedRoomIDs() else {
@@ -992,7 +1002,7 @@ class ClientProxy: ClientProxyProtocol {
for roomID in roomIdentifiers { for roomID in roomIdentifiers {
guard case let .joined(roomProxy) = await roomForIdentifier(roomID), guard case let .joined(roomProxy) = await roomForIdentifier(roomID),
filter(roomProxy) else { await filter(roomProxy) else {
continue continue
} }
@@ -1035,16 +1045,6 @@ class ClientProxy: ClientProxyProtocol {
return users.elements return users.elements
} }
private func recentlyVisitedRoomIDs() async -> Result<[String], ClientProxyError> {
do {
let result = try await client.getRecentlyVisitedRooms()
return .success(result)
} catch {
MXLog.error("Failed retrieving recently visited rooms with error: \(error)")
return .failure(.sdkError(error))
}
}
// MARK: Moderation & Safety // MARK: Moderation & Safety
func setTimelineMediaVisibility(_ value: TimelineMediaVisibility) async -> Result<Void, ClientProxyError> { func setTimelineMediaVisibility(_ value: TimelineMediaVisibility) async -> Result<Void, ClientProxyError> {
@@ -283,7 +283,9 @@ protocol ClientProxyProtocol: AnyObject {
func trackRecentlyVisitedRoom(_ roomID: String) async -> Result<Void, ClientProxyError> func trackRecentlyVisitedRoom(_ roomID: String) async -> Result<Void, ClientProxyError>
func recentlyVisitedRooms(filter: @Sendable (JoinedRoomProxyProtocol) -> Bool) async -> [JoinedRoomProxyProtocol] /// The identifiers of the rooms the user visited most recently, newest first.
func recentlyVisitedRoomIDs() async -> Result<[String], ClientProxyError>
func recentlyVisitedRooms(filter: @Sendable (JoinedRoomProxyProtocol) async -> Bool) async -> [JoinedRoomProxyProtocol]
func recentConversationCounterparts() async -> [UserProfile] func recentConversationCounterparts() async -> [UserProfile]
// MARK: - Crypto // MARK: - Crypto
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:90feb66a7ec36bae867ca32a2a883960a5efeab4822b0cb0136926295d8744da
size 158905
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:333ee064686f6d432b48a6fe38d34e801cc238ad34af28c7853f2ab794bbad46
size 160891
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4cb33b9bb83676d00fe204736824fca62327c17edd4deb0e2722286571b30fb2
size 107801
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c8cf4b81d5dfc381b74728e65bec8f947e01fa6a52cfd23201a4d473f2acff3d
size 107459
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:175fc36dbb3f8b53610b87a06fb8f69d9a7c03416699a732678dc719ea7227e0
size 170430
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b1a433bd71d9eae827259513c29d4765202c38aac1a97c4e6e0527ca624779ac
size 174737
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:143c4962bc624c86d81996a122894b18b76acbd587942942ca736f3ac5213c5d
size 118839
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:78e40d11b882b09e271b345bbbe941216d15629a501191382ed5047e0f227c54
size 119142
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c1466071c1f9dbee5bdd27a85a05de3f80eb0f5b09ce99739d8386dc234870ea
size 161363
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:70f396c9aba014af49da5528bda8762ccf837de55e88b57d9f39ff923cd2fcc3
size 162736
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b22eda91f8a662a8ae706fc4b48a68bf39c9f607e79e52459cd247cc06865443
size 111656
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9dadf1793a775b966312dd8c84b5b7b45fb00e59ce96f325ce9644ef540b4a7b
size 110350
@@ -37,6 +37,109 @@ struct MessageForwardingScreenViewModelTests {
#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")
} }
@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 @Test
mutating func roomSelection() { mutating func roomSelection() {
context.send(viewAction: .selectRoom(roomID: "2")) context.send(viewAction: .selectRoom(roomID: "2"))
@@ -181,9 +284,13 @@ struct MessageForwardingScreenViewModelTests {
// MARK: - Helpers // MARK: - Helpers
private func makeViewModel(userIndicatorController: UserIndicatorControllerProtocol) -> MessageForwardingScreenViewModelProtocol { private func makeViewModel(recentlyVisitedRoomIDs: [String] = [],
userIndicatorController: UserIndicatorControllerProtocol = UserIndicatorControllerMock()) -> MessageForwardingScreenViewModelProtocol {
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)
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, return MessageForwardingScreenViewModel(forwardingPayload: forwardingPayload,
userSession: UserSessionMock(.init(clientProxy: clientProxy)), userSession: UserSessionMock(.init(clientProxy: clientProxy)),