mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-01 01:03:01 +00:00
f91b7441e7
* 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.
301 lines
13 KiB
Swift
301 lines
13 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2022-2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import Combine
|
|
@testable import ElementX
|
|
import MatrixRustSDKMocks
|
|
import Testing
|
|
|
|
@MainActor
|
|
struct MessageForwardingScreenViewModelTests {
|
|
let forwardingPayload = MessageForwardingPayload(ids: [.event(uniqueID: .init("t1"), eventOrTransactionID: .eventID("t1")),
|
|
.event(uniqueID: .init("t2"), eventOrTransactionID: .eventID("t2"))],
|
|
roomID: "1",
|
|
contents: [RoomMessageEventContentWithoutRelationSDKMock(),
|
|
RoomMessageEventContentWithoutRelationSDKMock()])
|
|
var viewModel: MessageForwardingScreenViewModelProtocol!
|
|
var context: MessageForwardingScreenViewModelType.Context!
|
|
|
|
init() {
|
|
let clientProxy = ClientProxyMock(.init())
|
|
clientProxy.roomForIdentifierClosure = { .joined(JoinedRoomProxyMock(.init(id: $0))) }
|
|
|
|
viewModel = MessageForwardingScreenViewModel(forwardingPayload: forwardingPayload,
|
|
userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
|
roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
|
|
userIndicatorController: UserIndicatorControllerMock())
|
|
context = viewModel.context
|
|
}
|
|
|
|
@Test
|
|
func initialState() {
|
|
#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"))
|
|
#expect(context.viewState.selectedRoomIDs == ["2"])
|
|
}
|
|
|
|
@Test
|
|
mutating func searching() async throws {
|
|
let deferred = deferFulfillment(context.$viewState) { state in
|
|
state.rooms.count == 1
|
|
}
|
|
|
|
context.searchQuery = "Second"
|
|
|
|
try await deferred.fulfill()
|
|
}
|
|
|
|
@Test
|
|
mutating func forwarding() async throws {
|
|
context.send(viewAction: .selectRoom(roomID: "2"))
|
|
#expect(context.viewState.selectedRoomIDs == ["2"])
|
|
|
|
let deferred = deferFulfillment(viewModel.actions) { action in
|
|
switch action {
|
|
case .sent(let roomIDs):
|
|
return roomIDs == ["2"]
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
context.send(viewAction: .send)
|
|
|
|
try await deferred.fulfill()
|
|
}
|
|
|
|
@Test
|
|
mutating func forwardingSendsEveryMessageToEveryRoomInOrder() async throws {
|
|
let clientProxy = ClientProxyMock(.init())
|
|
var roomProxies = [String: JoinedRoomProxyMock]()
|
|
clientProxy.roomForIdentifierClosure = { roomID in
|
|
let roomProxy = JoinedRoomProxyMock(.init(id: roomID))
|
|
roomProxies[roomID] = roomProxy
|
|
return .joined(roomProxy)
|
|
}
|
|
viewModel = MessageForwardingScreenViewModel(forwardingPayload: forwardingPayload,
|
|
userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
|
roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
|
|
userIndicatorController: UserIndicatorControllerMock())
|
|
context = viewModel.context
|
|
|
|
context.send(viewAction: .selectRoom(roomID: "2"))
|
|
context.send(viewAction: .selectRoom(roomID: "3"))
|
|
|
|
let deferred = deferFulfillment(viewModel.actions) { action in
|
|
if case .sent(let roomIDs) = action {
|
|
return Set(roomIDs) == ["2", "3"]
|
|
}
|
|
return false
|
|
}
|
|
context.send(viewAction: .send)
|
|
try await deferred.fulfill()
|
|
|
|
let expectedContents = forwardingPayload.contents.map { ObjectIdentifier($0) }
|
|
for roomID in ["2", "3"] {
|
|
let timeline = try #require(roomProxies[roomID]?.timeline as? TimelineProxyMock)
|
|
let sentContents = timeline.sendMessageEventContentReceivedInvocations.map { ObjectIdentifier($0) }
|
|
#expect(sentContents == expectedContents)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
mutating func forwardingToSeveralRoomsShowsAToast() async throws {
|
|
let userIndicatorController = UserIndicatorControllerMock()
|
|
viewModel = makeViewModel(userIndicatorController: userIndicatorController)
|
|
context = viewModel.context
|
|
|
|
context.send(viewAction: .selectRoom(roomID: "2"))
|
|
context.send(viewAction: .selectRoom(roomID: "3"))
|
|
|
|
let deferred = deferFulfillment(viewModel.actions) { action in
|
|
if case .sent = action {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
context.send(viewAction: .send)
|
|
try await deferred.fulfill()
|
|
|
|
let titles = userIndicatorController.submitIndicatorDelayReceivedInvocations.map(\.indicator.title)
|
|
#expect(titles == [L10n.screenRoomMessagesForwarded(2)])
|
|
}
|
|
|
|
@Test
|
|
mutating func forwardingToOneRoomDoesNotShowAToast() async throws {
|
|
let userIndicatorController = UserIndicatorControllerMock()
|
|
viewModel = makeViewModel(userIndicatorController: userIndicatorController)
|
|
context = viewModel.context
|
|
|
|
context.send(viewAction: .selectRoom(roomID: "2"))
|
|
|
|
let deferred = deferFulfillment(viewModel.actions) { action in
|
|
if case .sent = action {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
context.send(viewAction: .send)
|
|
try await deferred.fulfill()
|
|
|
|
// The flow opens the room instead.
|
|
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 0)
|
|
}
|
|
|
|
@Test
|
|
mutating func forwardingReportsRoomsThatCouldNotBeReached() async throws {
|
|
let clientProxy = ClientProxyMock(.init())
|
|
clientProxy.roomForIdentifierClosure = { roomID in
|
|
roomID == "3" ? nil : .joined(JoinedRoomProxyMock(.init(id: roomID)))
|
|
}
|
|
let userIndicatorController = UserIndicatorControllerMock()
|
|
viewModel = MessageForwardingScreenViewModel(forwardingPayload: forwardingPayload,
|
|
userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
|
roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
|
|
userIndicatorController: userIndicatorController)
|
|
context = viewModel.context
|
|
|
|
context.send(viewAction: .selectRoom(roomID: "2"))
|
|
context.send(viewAction: .selectRoom(roomID: "3"))
|
|
|
|
let deferred = deferFulfillment(viewModel.actions) { action in
|
|
if case .sent(let roomIDs) = action {
|
|
return roomIDs == ["2"]
|
|
}
|
|
return false
|
|
}
|
|
context.send(viewAction: .send)
|
|
try await deferred.fulfill()
|
|
|
|
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
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)),
|
|
roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
|
|
userIndicatorController: userIndicatorController)
|
|
}
|
|
}
|