diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 5501d8333..cf365ef51 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -845,6 +845,7 @@ 8A5064CAC8E5F3B18645621D /* CallNotificationRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD6E082B0507FB28F966516A /* CallNotificationRoomTimelineView.swift */; }; 8A83D715940378B9BA9F739E /* RoomInviterLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB58E4E8D6D634C246AD5C2 /* RoomInviterLabel.swift */; }; 8AA84EF202F2EFC8453A97BD /* SecureBackupRecoveryKeyScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E027C112740573D27765C /* SecureBackupRecoveryKeyScreenModels.swift */; }; + 8AAE2EB68802E837F568163C /* SignallingTimelineItemProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE61568A225F7AA068583AA7 /* SignallingTimelineItemProviderMock.swift */; }; 8AB8ED1051216546CB35FA0E /* UserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5E9C044BEB7C70B1378E91 /* UserSession.swift */; }; 8AEE47A14223423806A7653A /* LiveLocationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2FEFA393FC7D2870263012 /* LiveLocationSession.swift */; }; 8B1D5CE017EEC734CF5FE130 /* Encodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 260004737C573A56FA01E86E /* Encodable.swift */; }; @@ -2995,6 +2996,7 @@ ED60E4D2CD678E1EBF16F77A /* BlockedUsersScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreen.swift; sourceTree = ""; }; EDDE826EAB1BAB80C1104980 /* SpaceFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceFlowCoordinator.swift; sourceTree = ""; }; EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItemContent.swift; sourceTree = ""; }; + EE61568A225F7AA068583AA7 /* SignallingTimelineItemProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignallingTimelineItemProviderMock.swift; sourceTree = ""; }; EE8BCD14EFED23459A43FDFF /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; EEAB5662310AE73D93815134 /* JoinRoomScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinRoomScreenViewModelProtocol.swift; sourceTree = ""; }; EED6D8956E554CEDFD4FE00D /* LocationSharingScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSharingScreenViewModelTests.swift; sourceTree = ""; }; @@ -3839,6 +3841,7 @@ 7396A6491DEC64FCB66F60A1 /* RoomThreadListServiceProxyMock.swift */, 4AB29A2D95D3469B5F016655 /* SecureBackupControllerMock.swift */, 248649EBA5BC33DB93698734 /* SessionVerificationControllerProxyMock.swift */, + EE61568A225F7AA068583AA7 /* SignallingTimelineItemProviderMock.swift */, 7DB2A5FF1E68BA580A20D405 /* SpaceRoomListProxyMock.swift */, EF36FC3DE25B20B7FA91F1FD /* SpaceServiceProxyMock.swift */, B0F5CC38803B8382D2C63222 /* TimelineControllerFactoryMock.swift */, @@ -8979,6 +8982,7 @@ 1C8BC70A18060677E295A846 /* ShareToMapsAppActivity.swift in Sources */, 274CE3C986841D15FD530BF5 /* ShimmerModifier.swift in Sources */, 5038E69A5E6A89DE1A345E04 /* ShouldScrollOnKeyboardDidShow.swift in Sources */, + 8AAE2EB68802E837F568163C /* SignallingTimelineItemProviderMock.swift in Sources */, 77920AFA8091AC6B9F190C90 /* Signposter.swift in Sources */, 2E6AD068D7767BDC33626F1C /* SnapshotableGlassEffect.swift in Sources */, F08F7BC07CA9AEF5CD157918 /* Snapshotting.swift in Sources */, diff --git a/ElementX/Sources/Mocks/SignallingTimelineItemProviderMock.swift b/ElementX/Sources/Mocks/SignallingTimelineItemProviderMock.swift new file mode 100644 index 000000000..b2ee646d8 --- /dev/null +++ b/ElementX/Sources/Mocks/SignallingTimelineItemProviderMock.swift @@ -0,0 +1,118 @@ +// +// Copyright 2025 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. +// + +// periphery:ignore:all + +import Combine +import Foundation +import MatrixRustSDK + +/// A ``TimelineItemProviderMock`` subclass that optionally listens for ``UITestsSignalling`` signals +/// to drive pagination and incoming message simulation. +@MainActor +class SignallingTimelineItemProviderMock: TimelineItemProviderMock { + private let stateSubject: CurrentValueSubject<([TimelineItemProxy], TimelinePaginationState), Never> + + /// An array of item arrays that will be prepended in order on each paginate signal. + var backPaginationResponses: [[TimelineItemProxy]] = [] + /// An array of items that will be appended in order on each incoming message signal. + var incomingItems: [TimelineItemProxy] = [] + + private var client: UITestsSignalling.Client? + private var signalCancellable: AnyCancellable? + + init(itemProxies: [TimelineItemProxy] = [], + paginationState: TimelinePaginationState = .init(backward: .idle, forward: .endReached), + listenForSignals: Bool = false) { + stateSubject = CurrentValueSubject((itemProxies, paginationState)) + + super.init() + + self.itemProxies = itemProxies + + underlyingPaginationState = paginationState + underlyingKind = .live + underlyingUpdatePublisher = stateSubject.eraseToAnyPublisher() + underlyingMembershipChangePublisher = Empty().eraseToAnyPublisher() + + guard listenForSignals else { return } + + do { + let client = try UITestsSignalling.Client(mode: .app) + signalCancellable = client.signals.sink { [weak self] signal in + Task { @MainActor [weak self] in + guard let self else { return } + do { + try self.handleSignal(signal) + } catch { + MXLog.error(error.localizedDescription) + } + } + } + self.client = client + } catch { + fatalError("Failure setting up signalling: \(error)") + } + } + + // MARK: - Private + + private func handleSignal(_ signal: UITestsSignal) throws { + switch signal { + case .timeline(.paginate): + try simulateBackPagination() + case .timeline(.incomingMessage): + try simulateIncomingItem() + default: + break + } + } + + private func simulateIncomingItem() throws { + guard !incomingItems.isEmpty else { return } + let item = incomingItems.removeFirst() + let (items, paginationState) = stateSubject.value + let newItems = items + [item] + itemProxies = newItems + stateSubject.send((newItems, paginationState)) + try client?.send(.success) + } + + func inject(_ proxies: [TimelineItemProxy]) { + let (items, paginationState) = stateSubject.value + let newItems = items + proxies + itemProxies = newItems + stateSubject.send((newItems, paginationState)) + } + + func replaceLastItem(with proxy: TimelineItemProxy) { + var (items, paginationState) = stateSubject.value + guard !items.isEmpty else { return } + items[items.index(before: items.endIndex)] = proxy + itemProxies = items + stateSubject.send((items, paginationState)) + } + + private func simulateBackPagination() throws { + let (items, _) = stateSubject.value + + guard !backPaginationResponses.isEmpty else { + let newState = TimelinePaginationState(backward: .endReached, forward: .endReached) + underlyingPaginationState = newState + stateSubject.send((items, newState)) + return + } + + let newItems = backPaginationResponses.removeFirst() + let newPaginationState = TimelinePaginationState(backward: backPaginationResponses.isEmpty ? .endReached : .idle, + forward: .endReached) + itemProxies = newItems + items + underlyingPaginationState = newPaginationState + stateSubject.send((newItems + items, newPaginationState)) + try client?.send(.success) + } +} diff --git a/ElementX/Sources/Mocks/TimelineProxyMock.swift b/ElementX/Sources/Mocks/TimelineProxyMock.swift index 433099afc..edaaa03aa 100644 --- a/ElementX/Sources/Mocks/TimelineProxyMock.swift +++ b/ElementX/Sources/Mocks/TimelineProxyMock.swift @@ -8,11 +8,13 @@ import Combine import Foundation +import MatrixRustSDK extension TimelineProxyMock { struct Configuration { var isAutoUpdating = false var timelineStartReached = false + var timelineItemProvider: TimelineItemProviderProtocol? } @MainActor @@ -20,13 +22,18 @@ extension TimelineProxyMock { self.init() sendMessageEventContentReturnValue = .success(()) + sendMessageHtmlInReplyToEventIDIntentionalMentionsReturnValue = .success(()) + editNewContentReturnValue = .success(()) + buildMessageContentForHtmlIntentionalMentionsReturnValue = RoomMessageEventContentWithoutRelation(noHandle: .init()) paginateBackwardsRequestSizeReturnValue = .success(()) paginateForwardsRequestSizeReturnValue = .success(()) sendReadReceiptForTypeReturnValue = .success(()) createPollQuestionAnswersPollKindReturnValue = .success(()) editPollOriginalQuestionAnswersPollKindReturnValue = .success(()) - if configuration.isAutoUpdating { + if let provider = configuration.timelineItemProvider { + underlyingTimelineItemProvider = provider + } else if configuration.isAutoUpdating { underlyingTimelineItemProvider = AutoUpdatingTimelineItemProviderMock() } else { let timelineItemProvider = TimelineItemProviderMock() diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockTimelineController.swift index 4d398fea4..bf35182a8 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockTimelineController.swift @@ -18,8 +18,6 @@ class MockTimelineController: TimelineControllerProtocol { /// An array of timeline item arrays that will be inserted in order for each back pagination request. var backPaginationResponses: [[RoomTimelineItemProtocol]] = [] - /// An array of timeline items that will be appended in order when ``simulateIncomingItems()`` is called. - var incomingItems: [RoomTimelineItemProtocol] = [] var roomProxy: JoinedRoomProxyProtocol? var timelineProxy: TimelineProxyProtocol? @@ -46,8 +44,6 @@ class MockTimelineController: TimelineControllerProtocol { var timelineItemsTimestamp: [TimelineItemIdentifier: Date] = [:] - private var client: UITestsSignalling.Client? - static var mediaGallery: MockTimelineController { MockTimelineController(timelineKind: .media(.mediaFilesScreen), timelineItems: (0..<5).reduce([]) { partialResult, _ in partialResult + [RoomTimelineItemFixtures.separator] + RoomTimelineItemFixtures.mediaChunk @@ -61,7 +57,6 @@ class MockTimelineController: TimelineControllerProtocol { } init(timelineKind: TimelineKind = .live, - listenForSignals: Bool = false, timelineItems: [RoomTimelineItemProtocol] = RoomTimelineItemFixtures.default, timelineProxy: TimelineProxyProtocol? = nil) { self.timelineKind = timelineKind @@ -70,14 +65,6 @@ class MockTimelineController: TimelineControllerProtocol { callbacks.send(.paginationState(paginationState)) callbacks.send(.isLive(true)) - - guard listenForSignals else { return } - - do { - try startListening() - } catch { - fatalError("Failure setting up signalling: \(error)") - } } private(set) var focusOnEventCallCount = 0 @@ -96,14 +83,21 @@ class MockTimelineController: TimelineControllerProtocol { private(set) var paginateBackwardsCallCount = 0 func paginateBackwards(requestSize: UInt16) async -> Result { paginateBackwardsCallCount += 1 - paginationState = TimelinePaginationState(backward: .paginating, forward: .endReached) - - if client == nil { - try? await simulateBackPagination() + simulateBackPagination() + return .success(()) + } + + private func simulateBackPagination() { + defer { + paginationState = TimelinePaginationState(backward: backPaginationResponses.isEmpty ? .endReached : .idle, + forward: .endReached) } - return .success(()) + guard !backPaginationResponses.isEmpty else { return } + + let newItems = backPaginationResponses.removeFirst() + timelineItems.insert(contentsOf: newItems, at: 0) } private(set) var paginateForwardsCallCount = 0 @@ -328,63 +322,4 @@ class MockTimelineController: TimelineControllerProtocol { } return .success(()) } - - // MARK: - UI Test signalling - - /// The cancellable used for UI Tests signalling. - private var signalCancellable: AnyCancellable? - - /// Allows the simulation of server responses by listening for signals from UI tests. - private func startListening() throws { - let client = try UITestsSignalling.Client(mode: .app) - - signalCancellable = client.signals.sink { [weak self] signal in - Task { - do { - try await self?.handleSignal(signal) - } catch { - MXLog.error(error.localizedDescription) - } - } - } - - self.client = client - } - - /// Handles a UI test signal as necessary. - private func handleSignal(_ signal: UITestsSignal) async throws { - switch signal { - case .timeline(.paginate): - try await simulateBackPagination() - case .timeline(.incomingMessage): - try await simulateIncomingItem() - default: - break - } - } - - /// Appends the next incoming item to the `timelineItems` array. - private func simulateIncomingItem() async throws { - guard !incomingItems.isEmpty else { return } - - let incomingItem = incomingItems.removeFirst() - timelineItems.append(incomingItem) - - try client?.send(.success) - } - - /// Prepends the next chunk of items to the `timelineItems` array. - private func simulateBackPagination() async throws { - defer { - paginationState = TimelinePaginationState(backward: backPaginationResponses.isEmpty ? .endReached : .idle, - forward: .endReached) - } - - guard !backPaginationResponses.isEmpty else { return } - - let newItems = backPaginationResponses.removeFirst() - timelineItems.insert(contentsOf: newItems, at: 0) - - try client?.send(.success) - } } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 92965a023..71ef1aa5c 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -373,12 +373,14 @@ class MockScreen: Identifiable { case .roomSmallTimelineIncomingAndSmallPagination: let navigationStackCoordinator = NavigationStackCoordinator() - let timelineController = MockTimelineController(listenForSignals: true) - timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk - timelineController.backPaginationResponses = [RoomTimelineItemFixtures.singleMessageChunk] - timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage] + let itemProvider = SignallingTimelineItemProviderMock(itemProxies: RoomTimelineItemFixtures.smallChunkProxies, + listenForSignals: true) + itemProvider.backPaginationResponses = [RoomTimelineItemFixtures.singleMessageChunkProxies] + itemProvider.incomingItems = [RoomTimelineItemFixtures.incomingMessageProxy] + let roomProxy = JoinedRoomProxyMock(.init(name: "Small timeline", avatarURL: .mockMXCAvatar)) + let timelineController = makeSignallingTimelineController(itemProvider: itemProvider, roomProxy: roomProxy) let parameters = RoomScreenCoordinatorParameters(userSession: UserSessionMock(.init()), - roomProxy: JoinedRoomProxyMock(.init(name: "Small timeline", avatarURL: .mockMXCAvatar)), + roomProxy: roomProxy, timelineController: timelineController, mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(appSettings: appSettings), @@ -398,11 +400,13 @@ class MockScreen: Identifiable { case .roomSmallTimelineLargePagination: let navigationStackCoordinator = NavigationStackCoordinator() - let timelineController = MockTimelineController(listenForSignals: true) - timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk - timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk] + let itemProvider = SignallingTimelineItemProviderMock(itemProxies: RoomTimelineItemFixtures.smallChunkProxies, + listenForSignals: true) + itemProvider.backPaginationResponses = [RoomTimelineItemFixtures.largeChunkProxies] + let roomProxy = JoinedRoomProxyMock(.init(name: "Small timeline, paginating", avatarURL: .mockMXCAvatar)) + let timelineController = makeSignallingTimelineController(itemProvider: itemProvider, roomProxy: roomProxy) let parameters = RoomScreenCoordinatorParameters(userSession: UserSessionMock(.init()), - roomProxy: JoinedRoomProxyMock(.init(name: "Small timeline, paginating", avatarURL: .mockMXCAvatar)), + roomProxy: roomProxy, timelineController: timelineController, mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(appSettings: appSettings), @@ -421,11 +425,13 @@ class MockScreen: Identifiable { case .roomLayoutTop: let navigationStackCoordinator = NavigationStackCoordinator() - let timelineController = MockTimelineController(listenForSignals: true) - timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk - timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk] + let itemProvider = SignallingTimelineItemProviderMock(itemProxies: RoomTimelineItemFixtures.largeChunkProxies, + listenForSignals: true) + itemProvider.backPaginationResponses = [RoomTimelineItemFixtures.largeChunkProxies] + let roomProxy = JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: .mockMXCAvatar)) + let timelineController = makeSignallingTimelineController(itemProvider: itemProvider, roomProxy: roomProxy) let parameters = RoomScreenCoordinatorParameters(userSession: UserSessionMock(.init()), - roomProxy: JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: .mockMXCAvatar)), + roomProxy: roomProxy, timelineController: timelineController, mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(appSettings: appSettings), @@ -445,12 +451,14 @@ class MockScreen: Identifiable { case .roomLayoutMiddle: let navigationStackCoordinator = NavigationStackCoordinator() - let timelineController = MockTimelineController(listenForSignals: true) - timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk - timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk] - timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage] + let itemProvider = SignallingTimelineItemProviderMock(itemProxies: RoomTimelineItemFixtures.largeChunkProxies, + listenForSignals: true) + itemProvider.backPaginationResponses = [RoomTimelineItemFixtures.largeChunkProxies] + itemProvider.incomingItems = [RoomTimelineItemFixtures.incomingMessageProxy] + let roomProxy = JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: .mockMXCAvatar)) + let timelineController = makeSignallingTimelineController(itemProvider: itemProvider, roomProxy: roomProxy) let parameters = RoomScreenCoordinatorParameters(userSession: UserSessionMock(.init()), - roomProxy: JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: .mockMXCAvatar)), + roomProxy: roomProxy, timelineController: timelineController, mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(appSettings: appSettings), @@ -470,11 +478,13 @@ class MockScreen: Identifiable { case .roomLayoutBottom: let navigationStackCoordinator = NavigationStackCoordinator() - let timelineController = MockTimelineController(listenForSignals: true) - timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk - timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage] + let itemProvider = SignallingTimelineItemProviderMock(itemProxies: RoomTimelineItemFixtures.largeChunkProxies, + listenForSignals: true) + itemProvider.incomingItems = [RoomTimelineItemFixtures.incomingMessageProxy] + let roomProxy = JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: .mockMXCAvatar)) + let timelineController = makeSignallingTimelineController(itemProvider: itemProvider, roomProxy: roomProxy) let parameters = RoomScreenCoordinatorParameters(userSession: UserSessionMock(.init()), - roomProxy: JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: .mockMXCAvatar)), + roomProxy: roomProxy, timelineController: timelineController, mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(appSettings: appSettings), @@ -532,7 +542,6 @@ class MockScreen: Identifiable { let timelineController = MockTimelineController() timelineController.timelineItems = RoomTimelineItemFixtures.disclosedPolls - timelineController.incomingItems = [] let parameters = RoomScreenCoordinatorParameters(userSession: UserSessionMock(.init()), roomProxy: JoinedRoomProxyMock(.init(name: "Polls timeline", avatarURL: .mockMXCAvatar)), timelineController: timelineController, @@ -556,7 +565,6 @@ class MockScreen: Identifiable { let timelineController = MockTimelineController() timelineController.timelineItems = RoomTimelineItemFixtures.undisclosedPolls - timelineController.incomingItems = [] let parameters = RoomScreenCoordinatorParameters(userSession: UserSessionMock(.init()), roomProxy: JoinedRoomProxyMock(.init(name: "Polls timeline", avatarURL: .mockMXCAvatar)), timelineController: timelineController, @@ -580,7 +588,6 @@ class MockScreen: Identifiable { let timelineController = MockTimelineController() timelineController.timelineItems = RoomTimelineItemFixtures.outgoingPolls - timelineController.incomingItems = [] let parameters = RoomScreenCoordinatorParameters(userSession: UserSessionMock(.init()), roomProxy: JoinedRoomProxyMock(.init(name: "Polls timeline", avatarURL: .mockMXCAvatar)), timelineController: timelineController, @@ -618,6 +625,7 @@ class MockScreen: Identifiable { } else { .mockRooms } + let clientProxy = ClientProxyMock(.init(userID: "@mock:client.com", deviceID: "MOCKCLIENT", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(roomSummaries))), @@ -633,6 +641,13 @@ class MockScreen: Identifiable { let appMediator = AppMediatorMock(.init()) appMediator.underlyingWindowManager = windowManager + let itemProvider = SignallingTimelineItemProviderMock(itemProxies: RoomTimelineItemFixtures.largeChunkProxies) + + let timelineController = makeSignallingTimelineController(itemProvider: itemProvider, + roomProxy: JoinedRoomProxyMock(.init())) + + let timelineControllerFactory = TimelineControllerFactoryMock(.init(timelineController: timelineController)) + let flowCoordinator = UserSessionFlowCoordinator(isNewLogin: false, navigationRootCoordinator: navigationRootCoordinator, appLockService: AppLockService(keychainController: KeychainControllerMock(), @@ -640,7 +655,7 @@ class MockScreen: Identifiable { flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy)), bugReportService: BugReportServiceMock(.init()), elementCallService: ElementCallServiceMock(.init()), - timelineControllerFactory: TimelineControllerFactoryMock(.init()), + timelineControllerFactory: timelineControllerFactory, emojiProvider: EmojiProvider(appSettings: appSettings), linkMetadataProvider: LinkMetadataProvider(), appMediator: appMediator, @@ -852,4 +867,40 @@ class MockScreen: Identifiable { return nil } }() + + // MARK: - Private + + private func makeSignallingTimelineController(itemProvider: SignallingTimelineItemProviderMock, + roomProxy: JoinedRoomProxyMock) -> TimelineController { + let timelineProxy = TimelineProxyMock(.init(timelineItemProvider: itemProvider)) + + var pendingEditText: String? + + timelineProxy.sendMessageHtmlInReplyToEventIDIntentionalMentionsClosure = { [weak itemProvider] message, _, _, _ in + itemProvider?.inject([.mockOwnMessage(message)]) + return .success(()) + } + + timelineProxy.buildMessageContentForHtmlIntentionalMentionsClosure = { text, _, _ in + pendingEditText = text + return RoomMessageEventContentWithoutRelation(noHandle: .init()) + } + + timelineProxy.editNewContentClosure = { [weak itemProvider] _, _ in + if let text = pendingEditText { + itemProvider?.replaceLastItem(with: .mockOwnMessage(text, isEdited: true)) + pendingEditText = nil + } + return .success(()) + } + + return TimelineController(roomProxy: roomProxy, + timelineProxy: timelineProxy, + initialFocussedEventID: nil, + timelineItemFactory: RoomTimelineItemFactory(userID: "@alice:matrix.org", + attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()), + stateEventStringBuilder: RoomStateEventStringBuilder(userID: "@alice:matrix.org")), + mediaProvider: MediaProviderMock(.init()), + appSettings: appSettings) + } }