diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index ebad853d1..f6e4da813 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -234,9 +234,14 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol switch await timelineController.focusOnEvent(eventID, timelineSize: Constants.detachedTimelineSize) { case .success: state.timelineViewState.focussedEventID = eventID - case .failure: + case .failure(let error): MXLog.error("Failed to focus on event \(eventID)") - displayError(.toast(L10n.commonFailed)) + + if case .eventNotFound = error { + displayError(.toast(L10n.errorMessageNotFound)) + } else { + displayError(.toast(L10n.commonFailed)) + } } } diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 848550ca6..cf95a7ac5 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -163,8 +163,20 @@ class RoomProxy: RoomProxyProtocol { do { let timeline = try await room.timelineFocusedOnEvent(eventId: eventID, numContextEvents: numberOfEvents, internalIdPrefix: UUID().uuidString) return .success(TimelineProxy(timeline: timeline, isLive: false)) + } catch let error as FocusEventError { + switch error { + case .InvalidEventId(_, let error): + MXLog.error("Invalid event \(eventID) Error: \(error)") + return .failure(.eventNotFound) + case .EventNotFound: + MXLog.error("Event \(eventID) not found.") + return .failure(.eventNotFound) + case .Other(let message): + MXLog.error("Failed to create a timeline focussed on event \(eventID) Error: \(message)") + return .failure(.sdkError(error)) + } } catch { - MXLog.error("Failed to create a timeline focussed on: \(eventID) with error: \(error)") + MXLog.error("Unexpected error: \(error)") return .failure(.sdkError(error)) } } diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index e3e63b170..2cf03dfe4 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -23,6 +23,7 @@ enum RoomProxyError: Error { case invalidURL case invalidMedia + case eventNotFound } enum RoomProxyAction { diff --git a/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift b/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift index 2c3cf16a4..641fb6459 100644 --- a/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift +++ b/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift @@ -241,11 +241,19 @@ enum RoomTimelineItemFixtures { static var outgoingPolls: [RoomTimelineItemProtocol] { [PollRoomTimelineItem.mock(poll: .disclosed(createdByAccountOwner: true), isOutgoing: true)] } + + static var permalinkChunk: [RoomTimelineItemProtocol] { + (1...20).map { index in + TextRoomTimelineItem(id: .init(timelineID: "\(index)", eventID: "$\(index)"), + text: "Message ID \(index)", + senderDisplayName: index > 10 ? "Alice" : "Bob") + } + } } private extension TextRoomTimelineItem { - init(text: String, senderDisplayName: String) { - self.init(id: .random, + init(id: TimelineItemIdentifier? = nil, text: String, senderDisplayName: String) { + self.init(id: id ?? .random, timestamp: "10:47 am", isOutgoing: senderDisplayName == "Alice", isEditable: false, diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index c9d5f6dd1..5ba9e5986 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -66,8 +66,12 @@ class RoomTimelineController: RoomTimelineControllerProtocol { activeTimeline = timeline activeTimelineProvider = timeline.timelineProvider return .success(()) - case .failure: - return .failure(.generic) + case .failure(let error): + if case .eventNotFound = error { + return .failure(.eventNotFound) + } else { + return .failure(.generic) + } } } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift index 7f1e4de46..779950f1e 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift @@ -32,6 +32,7 @@ enum RoomTimelineControllerAction { enum RoomTimelineControllerError: Error { case generic + case eventNotFound } @MainActor diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 0a5072584..b6ae4cac2 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -95,6 +95,8 @@ class MockScreen: Identifiable { let windowManager: SecureWindowManagerProtocol let navigationRootCoordinator: NavigationRootCoordinator + private var client: UITestsSignalling.Client? + private var retainedState = [Any]() private var cancellables = Set() @@ -393,6 +395,37 @@ class MockScreen: Identifiable { navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator + case .roomLayoutHighlight: + let navigationStackCoordinator = NavigationStackCoordinator() + + let timelineController = MockRoomTimelineController() + timelineController.timelineItems = RoomTimelineItemFixtures.permalinkChunk + let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(name: "Timeline highlight", avatarURL: URL.picturesDirectory)), + timelineController: timelineController, + mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), + emojiProvider: EmojiProvider(), + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + appMediator: AppMediatorMock.default, + appSettings: ServiceLocator.shared.settings) + let coordinator = RoomScreenCoordinator(parameters: parameters) + navigationStackCoordinator.setRootCoordinator(coordinator) + + do { + let client = try UITestsSignalling.Client(mode: .app) + client.signals.sink { [weak self] signal in + guard case .timeline(.focusOnEvent(let eventID)) = signal else { return } + coordinator.focusOnEvent(eventID: eventID) + try? client.send(.success) + } + .store(in: &cancellables) + } catch { + fatalError("Failure setting up signalling: \(error)") + } + + self.client = client + return navigationStackCoordinator case .roomWithDisclosedPolls: let navigationStackCoordinator = NavigationStackCoordinator() diff --git a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift index 89fa15ef3..01a5f2645 100644 --- a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift +++ b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift @@ -34,6 +34,7 @@ enum UITestsScreenIdentifier: String { case roomLayoutBottom case roomLayoutMiddle case roomLayoutTop + case roomLayoutHighlight case roomMembersListScreenPendingInvites case roomPlainNoAvatar case roomRolesAndPermissionsFlow diff --git a/ElementX/Sources/UITests/UITestsSignalling.swift b/ElementX/Sources/UITests/UITestsSignalling.swift index a22946533..ebe2e0dc7 100644 --- a/ElementX/Sources/UITests/UITestsSignalling.swift +++ b/ElementX/Sources/UITests/UITestsSignalling.swift @@ -27,11 +27,13 @@ enum UITestsSignal: Codable, Equatable { case success case timeline(Timeline) - enum Timeline: Codable { + enum Timeline: Codable, Equatable { /// Ask the app to back paginate. case paginate /// Ask the app to simulate an incoming message. case incomingMessage + /// Ask the app to simulate focussing on an event ID. + case focusOnEvent(String) } /// Posts a notification. diff --git a/UITests/Sources/RoomScreenUITests.swift b/UITests/Sources/RoomScreenUITests.swift index b96cc3a84..c4d93488d 100644 --- a/UITests/Sources/RoomScreenUITests.swift +++ b/UITests/Sources/RoomScreenUITests.swift @@ -109,6 +109,25 @@ class RoomScreenUITests: XCTestCase { try await app.assertScreenshot(.roomLayoutBottom, step: 1) } + + func testTimelineLayoutHighlightExisting() async throws { + let client = try UITestsSignalling.Client(mode: .tests) + + let app = Application.launch(.roomLayoutHighlight) + + await client.waitForApp() + defer { try? client.stop() } + + // Some time for the timeline to settle. + try await Task.sleep(for: .seconds(1)) + // When tapping a permalink to an item in the timeline. + try await performOperation(.focusOnEvent("$5"), using: client) + // Some time for the timeline to settle. + try await Task.sleep(for: .seconds(1)) + + // Then the item should become highlighted. + try await app.assertScreenshot(.roomLayoutHighlight, step: 0) + } func testTimelineReadReceipts() async throws { let app = Application.launch(.roomSmallTimelineWithReadReceipts) diff --git a/UITests/Sources/__Snapshots__/Application/roomLayoutHighlight-0-iPad-10th-generation-en-GB.UI.png b/UITests/Sources/__Snapshots__/Application/roomLayoutHighlight-0-iPad-10th-generation-en-GB.UI.png new file mode 100644 index 000000000..bc0d72f1b --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/roomLayoutHighlight-0-iPad-10th-generation-en-GB.UI.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85ccfdd71e9fa2e59b65b9731c8e0135e7e44349d7f3debdb5afcff0262576d0 +size 233181 diff --git a/UITests/Sources/__Snapshots__/Application/roomLayoutHighlight-0-iPhone-15-en-GB.UI.png b/UITests/Sources/__Snapshots__/Application/roomLayoutHighlight-0-iPhone-15-en-GB.UI.png new file mode 100644 index 000000000..b39baa20f --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/roomLayoutHighlight-0-iPhone-15-en-GB.UI.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79e3b444b2682f3697c1f27c898be7a3ed50d230c60d5c0fd33bfe9125056802 +size 331437