// // Copyright 2022 New Vector Ltd // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // @testable import ElementX import Combine import XCTest @MainActor class RoomScreenViewModelTests: XCTestCase { var userIndicatorControllerMock: UserIndicatorControllerMock! var cancellables = Set() override func setUp() async throws { cancellables.removeAll() userIndicatorControllerMock = UserIndicatorControllerMock.default } override func tearDown() async throws { userIndicatorControllerMock = nil } // MARK: - Message Grouping func testMessageGrouping() { // Given 3 messages from Bob. let items = [ TextRoomTimelineItem(text: "Message 1", sender: "bob"), TextRoomTimelineItem(text: "Message 2", sender: "bob"), TextRoomTimelineItem(text: "Message 3", sender: "bob") ] // When showing them in a timeline. let timelineController = MockRoomTimelineController() timelineController.timelineItems = items let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "")), timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, application: ApplicationMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, notificationCenter: NotificationCenterMock()) // Then the messages should be grouped together. XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the middle message from being grouped.") XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.") } func testMessageGroupingMultipleSenders() { // Given some interleaved messages from Bob and Alice. let items = [ TextRoomTimelineItem(text: "Message 1", sender: "alice"), TextRoomTimelineItem(text: "Message 2", sender: "bob"), TextRoomTimelineItem(text: "Message 3", sender: "alice"), TextRoomTimelineItem(text: "Message 4", sender: "alice"), TextRoomTimelineItem(text: "Message 5", sender: "bob"), TextRoomTimelineItem(text: "Message 6", sender: "bob") ] // When showing them in a timeline. let timelineController = MockRoomTimelineController() timelineController.timelineItems = items let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "")), timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, application: ApplicationMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, notificationCenter: NotificationCenterMock()) // Then the messages should be grouped by sender. XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .single, "A message should not be grouped when the sender changes.") XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .single, "A message should not be grouped when the sender changes.") XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.") XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[3].groupStyle, .last, "A group should be ended when the sender changes in the next message.") XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[4].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.") XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[5].groupStyle, .last, "A group should be ended when the sender changes in the next message.") } func testMessageGroupingWithLeadingReactions() { // Given 3 messages from Bob where the first message has a reaction. let items = [ TextRoomTimelineItem(text: "Message 1", sender: "bob", addReactions: true), TextRoomTimelineItem(text: "Message 2", sender: "bob"), TextRoomTimelineItem(text: "Message 3", sender: "bob") ] // When showing them in a timeline. let timelineController = MockRoomTimelineController() timelineController.timelineItems = items let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "")), timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, application: ApplicationMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, notificationCenter: NotificationCenterMock()) // Then the first message should not be grouped but the other two should. XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .single, "When the first message has reactions it should not be grouped.") XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .first, "A new group should be made when the preceding message has reactions.") XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.") } func testMessageGroupingWithInnerReactions() { // Given 3 messages from Bob where the middle message has a reaction. let items = [ TextRoomTimelineItem(text: "Message 1", sender: "bob"), TextRoomTimelineItem(text: "Message 2", sender: "bob", addReactions: true), TextRoomTimelineItem(text: "Message 3", sender: "bob") ] // When showing them in a timeline. let timelineController = MockRoomTimelineController() timelineController.timelineItems = items let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "")), timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, application: ApplicationMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, notificationCenter: NotificationCenterMock()) // Then the first and second messages should be grouped and the last one should not. XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .last, "When the message has reactions, the group should end here.") XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .single, "The last message should not be grouped when the preceding message has reactions.") } func testMessageGroupingWithTrailingReactions() { // Given 3 messages from Bob where the last message has a reaction. let items = [ TextRoomTimelineItem(text: "Message 1", sender: "bob"), TextRoomTimelineItem(text: "Message 2", sender: "bob"), TextRoomTimelineItem(text: "Message 3", sender: "bob", addReactions: true) ] // When showing them in a timeline. let timelineController = MockRoomTimelineController() timelineController.timelineItems = items let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "")), timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, application: ApplicationMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, notificationCenter: NotificationCenterMock()) // Then the messages should be grouped together. XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the second message from being grouped.") XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .last, "Reactions on the last message should not prevent it from being grouped.") } // MARK: - User Details func testGoToUserDetailsSuccessNoDelay() async { // Setup let expectation = expectation(description: #function) let timelineController = MockRoomTimelineController() let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) let roomMemberMock = RoomMemberProxyMock() roomMemberMock.userID = "bob" roomProxyMock.getMemberUserIDReturnValue = .success(roomMemberMock) let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, application: ApplicationMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, notificationCenter: NotificationCenterMock()) viewModel.actions .sink { action in switch action { case .displayRoomMemberDetails(let member): XCTAssert(member === roomMemberMock) default: XCTFail("Did not received the expected action") } expectation.fulfill() } .store(in: &cancellables) // Test viewModel.context.send(viewAction: .tappedOnUser(userID: "bob")) await fulfillment(of: [expectation]) XCTAssert(userIndicatorControllerMock.submitIndicatorDelayCallsCount == 1) XCTAssert(roomProxyMock.getMemberUserIDCallsCount == 1) XCTAssertEqual(roomProxyMock.getMemberUserIDReceivedUserID, "bob") } func testGoToUserDetailsSuccessWithDelay() async { // Setup let timelineController = MockRoomTimelineController() let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) let roomMemberMock = RoomMemberProxyMock() roomMemberMock.userID = "bob" let expectation = XCTestExpectation(description: "Go to user details") roomProxyMock.getMemberUserIDClosure = { _ in .success(roomMemberMock) } let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, application: ApplicationMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, notificationCenter: NotificationCenterMock()) viewModel.actions .sink { action in switch action { case .displayRoomMemberDetails(let member): XCTAssert(member === roomMemberMock) expectation.fulfill() default: XCTFail("Did not received the expected action") } } .store(in: &cancellables) // Test viewModel.context.send(viewAction: .tappedOnUser(userID: "bob")) await fulfillment(of: [expectation]) XCTAssert(userIndicatorControllerMock.submitIndicatorDelayCallsCount == 1) XCTAssert(userIndicatorControllerMock.retractIndicatorWithIdCallsCount == 1) XCTAssert(roomProxyMock.getMemberUserIDCallsCount == 1) XCTAssertEqual(roomProxyMock.getMemberUserIDReceivedUserID, "bob") } func testGoToUserDetailsFailure() async throws { // Setup let timelineController = MockRoomTimelineController() let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) let roomMemberMock = RoomMemberProxyMock() roomMemberMock.userID = "bob" roomProxyMock.getMemberUserIDClosure = { _ in .failure(.failedRetrievingMember) } let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, application: ApplicationMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, notificationCenter: NotificationCenterMock()) viewModel.actions .sink { _ in XCTFail("Should not receive any action") } .store(in: &cancellables) let deferred = deferFulfillment(viewModel.context.$viewState) { value in value.bindings.alertInfo != nil } viewModel.context.send(viewAction: .tappedOnUser(userID: "bob")) try await deferred.fulfill() XCTAssertFalse(viewModel.state.bindings.alertInfo.isNil) XCTAssert(roomProxyMock.getMemberUserIDCallsCount == 1) XCTAssertEqual(roomProxyMock.getMemberUserIDReceivedUserID, "bob") } // MARK: - Sending func testRetrySend() async throws { let timelineController = MockRoomTimelineController() let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) timelineController.roomProxy = roomProxyMock let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, application: ApplicationMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, notificationCenter: NotificationCenterMock()) viewModel.context.send(viewAction: .retrySend(itemID: .init(timelineID: UUID().uuidString, transactionID: "test retry send id"))) try? await Task.sleep(for: .milliseconds(100)) XCTAssert(roomProxyMock.retrySendTransactionIDCallsCount == 1) XCTAssert(roomProxyMock.retrySendTransactionIDReceivedInvocations == ["test retry send id"]) } func testRetrySendNoTransactionID() async { let timelineController = MockRoomTimelineController() let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, application: ApplicationMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, notificationCenter: NotificationCenterMock()) viewModel.context.send(viewAction: .retrySend(itemID: .random)) try? await Task.sleep(for: .milliseconds(100)) XCTAssert(roomProxyMock.retrySendTransactionIDCallsCount == 0) } func testCancelSend() async { let timelineController = MockRoomTimelineController() let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) timelineController.roomProxy = roomProxyMock let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, application: ApplicationMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, notificationCenter: NotificationCenterMock()) viewModel.context.send(viewAction: .cancelSend(itemID: .init(timelineID: UUID().uuidString, transactionID: "test cancel send id"))) try? await Task.sleep(for: .milliseconds(100)) XCTAssert(roomProxyMock.cancelSendTransactionIDCallsCount == 1) XCTAssert(roomProxyMock.cancelSendTransactionIDReceivedInvocations == ["test cancel send id"]) } func testCancelSendNoTransactionID() async { let timelineController = MockRoomTimelineController() let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, application: ApplicationMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, notificationCenter: NotificationCenterMock()) viewModel.context.send(viewAction: .cancelSend(itemID: .random)) try? await Task.sleep(for: .milliseconds(100)) XCTAssert(roomProxyMock.cancelSendTransactionIDCallsCount == 0) } // MARK: - Read Receipts // swiftlint:disable force_unwrapping func testSendReadReceipt() async throws { // Given a room with only text items in the timeline let items = [TextRoomTimelineItem(eventID: "t1"), TextRoomTimelineItem(eventID: "t2"), TextRoomTimelineItem(eventID: "t3")] let (viewModel, roomProxy, _, notificationCenter) = readReceiptsConfiguration(with: items) // When sending a read receipt for the last item. viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(items.last!.id)) try await Task.sleep(for: .milliseconds(100)) // Then the receipt should be sent. XCTAssertEqual(roomProxy.sendReadReceiptForCalled, true) XCTAssertEqual(roomProxy.sendReadReceiptForReceivedEventID, "t3") // And the notifications should be cleared. XCTAssertEqual(notificationCenter.postNameObjectReceivedArguments?.aName, .roomMarkedAsRead) let roomID = notificationCenter.postNameObjectReceivedArguments?.anObject as? String XCTAssertEqual(roomID, roomProxy.id) } func testSendMoreReadReceipts() async throws { // Given a room with only text items in the timeline that are all read. let items = [TextRoomTimelineItem(eventID: "t1"), TextRoomTimelineItem(eventID: "t2"), TextRoomTimelineItem(eventID: "t3")] let (viewModel, roomProxy, timelineController, _) = readReceiptsConfiguration(with: items) viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(items.last!.id)) try await Task.sleep(for: .milliseconds(100)) XCTAssertEqual(roomProxy.sendReadReceiptForCallsCount, 1) XCTAssertEqual(roomProxy.sendReadReceiptForReceivedEventID, "t3") // When sending a receipt for the first item in the timeline. viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(items.first!.id)) try await Task.sleep(for: .milliseconds(100)) // Then the request should be ignored. XCTAssertEqual(roomProxy.sendReadReceiptForCallsCount, 1) XCTAssertEqual(roomProxy.sendReadReceiptForReceivedEventID, "t3") // When a new message is received and marked as read. let newMessage = TextRoomTimelineItem(eventID: "t4") timelineController.timelineItems.append(newMessage) timelineController.callbacks.send(.updatedTimelineItems) try await Task.sleep(for: .milliseconds(100)) viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(newMessage.id)) try await Task.sleep(for: .milliseconds(100)) // Then the request should be made. XCTAssertEqual(roomProxy.sendReadReceiptForCallsCount, 2) XCTAssertEqual(roomProxy.sendReadReceiptForReceivedEventID, "t4") } func testSendReadReceiptWithoutEvents() async throws { // Given a room with only virtual items. let items = [SeparatorRoomTimelineItem(timelineID: "v1"), SeparatorRoomTimelineItem(timelineID: "v2"), SeparatorRoomTimelineItem(timelineID: "v3")] let (viewModel, roomProxy, _, _) = readReceiptsConfiguration(with: items) // When sending a read receipt for the last item. viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(items.last!.id)) try await Task.sleep(for: .milliseconds(100)) // Then nothing should be sent. XCTAssertEqual(roomProxy.sendReadReceiptForCalled, false) } func testSendReadReceiptVirtualLast() async throws { // Given a room where the last event is a virtual item. let items: [RoomTimelineItemProtocol] = [TextRoomTimelineItem(eventID: "t1"), TextRoomTimelineItem(eventID: "t2"), SeparatorRoomTimelineItem(timelineID: "v3")] let (viewModel, roomProxy, _, _) = readReceiptsConfiguration(with: items) // When sending a read receipt for the last item. viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(items.last!.id)) try await Task.sleep(for: .milliseconds(100)) // Then a read receipt should be sent for the item before it. XCTAssertEqual(roomProxy.sendReadReceiptForCalled, true) XCTAssertEqual(roomProxy.sendReadReceiptForReceivedEventID, "t2") } func testSendReadReceiptMultipleRequests() async throws { // Given a room where the last event is a virtual item which was already read. let items: [RoomTimelineItemProtocol] = [TextRoomTimelineItem(eventID: "t1"), TextRoomTimelineItem(eventID: "t2"), SeparatorRoomTimelineItem(timelineID: "v3")] let (viewModel, roomProxy, _, _) = readReceiptsConfiguration(with: items) viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(items.last!.id)) try await Task.sleep(for: .milliseconds(100)) XCTAssertEqual(roomProxy.sendReadReceiptForCallsCount, 1) XCTAssertEqual(roomProxy.sendReadReceiptForReceivedEventID, "t2") // When sending the same receipt again viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(items.last!.id)) try await Task.sleep(for: .milliseconds(100)) // Then the second call should be ignored. XCTAssertEqual(roomProxy.sendReadReceiptForCallsCount, 1) } // swiftlint:enable force_unwrapping // swiftlint:disable:next large_tuple private func readReceiptsConfiguration(with items: [RoomTimelineItemProtocol]) -> (RoomScreenViewModel, RoomProxyMock, MockRoomTimelineController, NotificationCenterMock) { let notificationCenter = NotificationCenterMock() let roomProxy = RoomProxyMock(with: .init(displayName: "")) let timelineController = MockRoomTimelineController() roomProxy.sendReadReceiptForReturnValue = .success(()) roomProxy.underlyingHasUnreadNotifications = true timelineController.timelineItems = items timelineController.roomProxy = roomProxy let viewModel = RoomScreenViewModel(roomProxy: roomProxy, timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, application: ApplicationMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, notificationCenter: notificationCenter) return (viewModel, roomProxy, timelineController, notificationCenter) } func testShowReadReceipts() async throws { let receipts: [ReadReceipt] = [.init(userID: "@alice:matrix.org", formattedTimestamp: "12:00"), .init(userID: "@charlie:matrix.org", formattedTimestamp: "11:00")] // Given 3 messages from Bob where the middle message has a reaction. let message = TextRoomTimelineItem(text: "Test", sender: "bob", addReadReceipts: receipts) let id = message.id // When showing them in a timeline. let timelineController = MockRoomTimelineController() timelineController.timelineItems = [message] let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(displayName: "", members: [RoomMemberProxyMock.mockAlice, RoomMemberProxyMock.mockCharlie])), timelineController: timelineController, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: userIndicatorControllerMock, application: ApplicationMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics, notificationCenter: NotificationCenterMock()) let deferred = deferFulfillment(viewModel.context.$viewState) { value in value.bindings.readReceiptsSummaryInfo?.orderedReceipts == receipts } viewModel.context.send(viewAction: .showReadReceipts(itemID: id)) try await deferred.fulfill() } } private extension TextRoomTimelineItem { init(text: String, sender: String, addReactions: Bool = false, addReadReceipts: [ReadReceipt] = []) { let reactions = addReactions ? [AggregatedReaction(accountOwnerID: "bob", key: "🦄", senders: [ReactionSender(senderID: sender, timestamp: Date())])] : [] self.init(id: .random, timestamp: "10:47 am", isOutgoing: sender == "bob", isEditable: sender == "bob", canBeRepliedTo: true, isThreaded: false, sender: .init(id: "@\(sender):server.com", displayName: sender), content: .init(body: text), properties: RoomTimelineItemProperties(reactions: reactions, orderedReadReceipts: addReadReceipts)) } } private extension SeparatorRoomTimelineItem { init(timelineID: String) { self.init(id: .init(timelineID: timelineID), text: "") } } private extension TextRoomTimelineItem { init(eventID: String) { self.init(id: .init(timelineID: UUID().uuidString, eventID: eventID), timestamp: "", isOutgoing: false, isEditable: false, canBeRepliedTo: true, isThreaded: false, sender: .init(id: ""), content: .init(body: "Hello, World!")) } }