diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index f4e9f148c2..b307954fd3 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -15,7 +15,7 @@ jobs: - name: Setup environment run: - brew install danger/tap/danger-swift + brew install danger/tap/danger-swift swiftlint - name: Danger run: diff --git a/.swiftlint.yml b/.swiftlint.yml index 0825fb8807..e65fa46635 100755 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -41,6 +41,10 @@ function_body_length: warning: 100 error: 100 +function_parameter_count: + warning: 10 + error: 10 + cyclomatic_complexity: ignores_case_statements: true diff --git a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift index 6fcdc64650..38754db2ce 100644 --- a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift @@ -64,7 +64,8 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()), stateEventStringBuilder: RoomStateEventStringBuilder(userID: userSession.clientProxy.userID)) - guard case let .success(mediaTimelineController) = await timelineControllerFactory.buildMessageFilteredTimelineController(allowedMessageTypes: [.image, .video], + guard case let .success(mediaTimelineController) = await timelineControllerFactory.buildMessageFilteredTimelineController(focus: .live, + allowedMessageTypes: [.image, .video], presentation: .mediaFilesScreen, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, @@ -73,7 +74,8 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { return } - guard case let .success(filesTimelineController) = await timelineControllerFactory.buildMessageFilteredTimelineController(allowedMessageTypes: [.file, .audio], + guard case let .success(filesTimelineController) = await timelineControllerFactory.buildMessageFilteredTimelineController(focus: .live, + allowedMessageTypes: [.file, .audio], presentation: .mediaFilesScreen, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index dc90cec10e..cc83ac9696 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -6158,15 +6158,15 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable { } //MARK: - messageFilteredTimeline - var messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingCallsCount = 0 - var messageFilteredTimelineAllowedMessageTypesPresentationCallsCount: Int { + var messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount = 0 + var messageFilteredTimelineFocusAllowedMessageTypesPresentationCallsCount: Int { get { if Thread.isMainThread { - return messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingCallsCount + return messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount } else { var returnValue: Int? = nil DispatchQueue.main.sync { - returnValue = messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingCallsCount + returnValue = messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount } return returnValue! @@ -6174,29 +6174,29 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable { } set { if Thread.isMainThread { - messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingCallsCount = newValue + messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount = newValue } else { DispatchQueue.main.sync { - messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingCallsCount = newValue + messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount = newValue } } } } - var messageFilteredTimelineAllowedMessageTypesPresentationCalled: Bool { - return messageFilteredTimelineAllowedMessageTypesPresentationCallsCount > 0 + var messageFilteredTimelineFocusAllowedMessageTypesPresentationCalled: Bool { + return messageFilteredTimelineFocusAllowedMessageTypesPresentationCallsCount > 0 } - var messageFilteredTimelineAllowedMessageTypesPresentationReceivedArguments: (allowedMessageTypes: [RoomMessageEventMessageType], presentation: TimelineKind.MediaPresentation)? - var messageFilteredTimelineAllowedMessageTypesPresentationReceivedInvocations: [(allowedMessageTypes: [RoomMessageEventMessageType], presentation: TimelineKind.MediaPresentation)] = [] + var messageFilteredTimelineFocusAllowedMessageTypesPresentationReceivedArguments: (focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation)? + var messageFilteredTimelineFocusAllowedMessageTypesPresentationReceivedInvocations: [(focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation)] = [] - var messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingReturnValue: Result! - var messageFilteredTimelineAllowedMessageTypesPresentationReturnValue: Result! { + var messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingReturnValue: Result! + var messageFilteredTimelineFocusAllowedMessageTypesPresentationReturnValue: Result! { get { if Thread.isMainThread { - return messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingReturnValue + return messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingReturnValue } else { var returnValue: Result? = nil DispatchQueue.main.sync { - returnValue = messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingReturnValue + returnValue = messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingReturnValue } return returnValue! @@ -6204,26 +6204,26 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable { } set { if Thread.isMainThread { - messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingReturnValue = newValue + messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingReturnValue = newValue } else { DispatchQueue.main.sync { - messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingReturnValue = newValue + messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingReturnValue = newValue } } } } - var messageFilteredTimelineAllowedMessageTypesPresentationClosure: (([RoomMessageEventMessageType], TimelineKind.MediaPresentation) async -> Result)? + var messageFilteredTimelineFocusAllowedMessageTypesPresentationClosure: ((TimelineFocus, [TimelineAllowedMessageType], TimelineKind.MediaPresentation) async -> Result)? - func messageFilteredTimeline(allowedMessageTypes: [RoomMessageEventMessageType], presentation: TimelineKind.MediaPresentation) async -> Result { - messageFilteredTimelineAllowedMessageTypesPresentationCallsCount += 1 - messageFilteredTimelineAllowedMessageTypesPresentationReceivedArguments = (allowedMessageTypes: allowedMessageTypes, presentation: presentation) + func messageFilteredTimeline(focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation) async -> Result { + messageFilteredTimelineFocusAllowedMessageTypesPresentationCallsCount += 1 + messageFilteredTimelineFocusAllowedMessageTypesPresentationReceivedArguments = (focus: focus, allowedMessageTypes: allowedMessageTypes, presentation: presentation) DispatchQueue.main.async { - self.messageFilteredTimelineAllowedMessageTypesPresentationReceivedInvocations.append((allowedMessageTypes: allowedMessageTypes, presentation: presentation)) + self.messageFilteredTimelineFocusAllowedMessageTypesPresentationReceivedInvocations.append((focus: focus, allowedMessageTypes: allowedMessageTypes, presentation: presentation)) } - if let messageFilteredTimelineAllowedMessageTypesPresentationClosure = messageFilteredTimelineAllowedMessageTypesPresentationClosure { - return await messageFilteredTimelineAllowedMessageTypesPresentationClosure(allowedMessageTypes, presentation) + if let messageFilteredTimelineFocusAllowedMessageTypesPresentationClosure = messageFilteredTimelineFocusAllowedMessageTypesPresentationClosure { + return await messageFilteredTimelineFocusAllowedMessageTypesPresentationClosure(focus, allowedMessageTypes, presentation) } else { - return messageFilteredTimelineAllowedMessageTypesPresentationReturnValue + return messageFilteredTimelineFocusAllowedMessageTypesPresentationReturnValue } } //MARK: - enableEncryption @@ -14488,15 +14488,15 @@ class TimelineControllerFactoryMock: TimelineControllerFactoryProtocol, @uncheck } //MARK: - buildMessageFilteredTimelineController - var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0 - var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount: Int { + var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0 + var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount: Int { get { if Thread.isMainThread { - return buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount + return buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount } else { var returnValue: Int? = nil DispatchQueue.main.sync { - returnValue = buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount + returnValue = buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount } return returnValue! @@ -14504,29 +14504,29 @@ class TimelineControllerFactoryMock: TimelineControllerFactoryProtocol, @uncheck } set { if Thread.isMainThread { - buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue + buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue } else { DispatchQueue.main.sync { - buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue + buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue } } } } - var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCalled: Bool { - return buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount > 0 + var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCalled: Bool { + return buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount > 0 } - var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedArguments: (allowedMessageTypes: [RoomMessageEventMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)? - var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations: [(allowedMessageTypes: [RoomMessageEventMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)] = [] + var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedArguments: (focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)? + var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations: [(focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)] = [] - var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue: Result! - var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReturnValue: Result! { + var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue: Result! + var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReturnValue: Result! { get { if Thread.isMainThread { - return buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue + return buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue } else { var returnValue: Result? = nil DispatchQueue.main.sync { - returnValue = buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue + returnValue = buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue } return returnValue! @@ -14534,26 +14534,26 @@ class TimelineControllerFactoryMock: TimelineControllerFactoryProtocol, @uncheck } set { if Thread.isMainThread { - buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue + buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue } else { DispatchQueue.main.sync { - buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue + buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue } } } } - var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure: (([RoomMessageEventMessageType], TimelineKind.MediaPresentation, JoinedRoomProxyProtocol, RoomTimelineItemFactoryProtocol, MediaProviderProtocol) async -> Result)? + var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure: ((TimelineFocus, [TimelineAllowedMessageType], TimelineKind.MediaPresentation, JoinedRoomProxyProtocol, RoomTimelineItemFactoryProtocol, MediaProviderProtocol) async -> Result)? - func buildMessageFilteredTimelineController(allowedMessageTypes: [RoomMessageEventMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> Result { - buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount += 1 - buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedArguments = (allowedMessageTypes: allowedMessageTypes, presentation: presentation, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider) + func buildMessageFilteredTimelineController(focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> Result { + buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount += 1 + buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedArguments = (focus: focus, allowedMessageTypes: allowedMessageTypes, presentation: presentation, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider) DispatchQueue.main.async { - self.buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations.append((allowedMessageTypes: allowedMessageTypes, presentation: presentation, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider)) + self.buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations.append((focus: focus, allowedMessageTypes: allowedMessageTypes, presentation: presentation, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider)) } - if let buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure = buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure { - return await buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure(allowedMessageTypes, presentation, roomProxy, timelineItemFactory, mediaProvider) + if let buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure = buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure { + return await buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure(focus, allowedMessageTypes, presentation, roomProxy, timelineItemFactory, mediaProvider) } else { - return buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReturnValue + return buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReturnValue } } } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift index f959e2e558..30fd1d4b1d 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift @@ -41,9 +41,16 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource { previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.Media.init) self.initialItem = initialItem - let initialItemArrayIndex = previewItems.firstIndex { $0.id == initialItem.id } ?? 0 - initialItemIndex = initialItemArrayIndex + initialPadding - currentItem = .media(previewItems[initialItemArrayIndex]) + if let initialItemArrayIndex = previewItems.firstIndex(where: { $0.id == initialItem.id.eventOrTransactionID }) { + initialItemIndex = initialItemArrayIndex + initialPadding + currentItem = .media(previewItems[initialItemArrayIndex]) + } else { + // The timeline hasn't loaded the initial item yet, so replace the whatever was loaded with + // the item the user wants to preview. + initialItemIndex = initialPadding + previewItems = [.init(timelineItem: initialItem)] + currentItem = .media(previewItems[0]) + } backwardPadding = initialPadding forwardPadding = initialPadding @@ -83,10 +90,18 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource { hasPaginated = true } } else { - // Do nothing! Not ideal but if we reload the data source the current item will - // also be, reloaded resetting any interaction the user has made with it. If we - // ignore the pagination, then the next time they swipe they'll land on a different + // When the timeline is loading items from the store and the initial item is the only + // preview in the array, we don't want to wipe it out, so if the existing items aren't + // found within the new items then let's ignore the update for now. This comes with a + // tradeoff that when a media gets redacted, no more previews will be added to the viewer. + // + // Note for the future if anyone wants to fix the redaction issue: Reloading the data source, + // will also reload the current item resetting any interaction the user has made with it. + // If you ignore the pagination, then the next time they swipe they'll land on a different // media but this is probably less jarring overall. I hate QLPreviewController! + + MXLog.info("Ignoring update: unable to find existing preview items range.") + return } previewItems = newItems @@ -109,9 +124,9 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource { let arrayIndex = index - backwardPadding if index < firstPreviewItemIndex { - return paginationState.backward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineStart : .paginating + return paginationState.backward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineStart : .paginatingBackwards } else if index > lastPreviewItemIndex { - return paginationState.forward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineEnd : .paginating + return paginationState.forward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineEnd : .paginatingForwards } else { return previewItems[arrayIndex] } @@ -151,7 +166,15 @@ enum TimelineMediaPreviewItem: Equatable { // MARK: Identifiable - var id: TimelineItemIdentifier { timelineItem.id } + /// The timeline item's event or transaction ID. + /// + /// We're identifying items by this to ensure that all matching is made using only this part of the identifier. This is + /// because the unique ID will be different across timelines so when the initial item comes from a regular timeline and + /// we build a filtered timeline to fetch the other media items, it is impossible to match by the `TimelineItemIdentifier`. + var id: TimelineItemIdentifier.EventOrTransactionID { + guard let id = timelineItem.id.eventOrTransactionID else { fatalError("Virtual items cannot be previewed.") } + return id + } // MARK: QLPreviewItem @@ -274,11 +297,12 @@ enum TimelineMediaPreviewItem: Equatable { } class Loading: NSObject, QLPreviewItem { - static let paginating = Loading(state: .paginating) + static let paginatingBackwards = Loading(state: .paginating(.backwards)) + static let paginatingForwards = Loading(state: .paginating(.forwards)) static let timelineStart = Loading(state: .timelineStart) static let timelineEnd = Loading(state: .timelineEnd) - enum State { case paginating, timelineStart, timelineEnd } + enum State { case paginating(PaginationDirection), timelineStart, timelineEnd } let state: State let previewItemURL: URL? = nil diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift index 753bc21dde..e3c6cfd869 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift @@ -14,7 +14,7 @@ enum TimelineMediaPreviewViewModelAction: Equatable { } enum TimelineMediaPreviewDriverAction { - case itemLoaded(TimelineItemIdentifier) + case itemLoaded(TimelineItemIdentifier.EventOrTransactionID) case showItemDetails(TimelineMediaPreviewItem.Media) case exportFile(TimelineMediaPreviewFileExportPicker.File) case authorizationRequired(appMediator: AppMediatorProtocol) diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift index 7f14d6c759..0f510f9919 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift @@ -63,7 +63,11 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { timelineViewModel.context.$viewState.map(\.timelineState.paginationState) .removeDuplicates() - .weakAssign(to: \.state.dataSource.paginationState, on: self) + .sink { [weak self] paginationState in + guard let self else { return } + state.dataSource.paginationState = paginationState + paginateIfNeeded() + } .store(in: &cancellables) } @@ -77,7 +81,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { switch action { case .viewInRoomTimeline: state.previewControllerDriver.send(.dismissDetailsSheet) - actionsSubject.send(.viewInRoomTimeline(item.id)) + actionsSubject.send(.viewInRoomTimeline(item.timelineItem.id)) case .save: Task { await saveCurrentItem() } case .redact: @@ -111,6 +115,23 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { mediaItem.downloadError = error } } + } else { + paginateIfNeeded() + } + } + + private func paginateIfNeeded() { + switch state.currentItem { + case .loading(.paginatingBackwards): + if state.dataSource.paginationState.backward == .idle { + timelineViewModel.context.send(viewAction: .paginateBackwards) + } + case .loading(.paginatingForwards): + if state.dataSource.paginationState.forward == .idle { + timelineViewModel.context.send(viewAction: .paginateForwards) + } + default: + break } } @@ -166,7 +187,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { } private func redactItem(_ item: TimelineMediaPreviewItem.Media) { - timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact)) + timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.timelineItem.id, action: .redact)) state.bindings.redactConfirmationItem = nil state.previewControllerDriver.send(.dismissDetailsSheet) actionsSubject.send(.dismiss) diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewController.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewController.swift index d2bf80d225..2c3f560f6e 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewController.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewController.swift @@ -182,7 +182,7 @@ class TimelineMediaPreviewController: QLPreviewController { } } - private func handleFileLoaded(itemID: TimelineItemIdentifier) { + private func handleFileLoaded(itemID: TimelineItemIdentifier.EventOrTransactionID) { guard (currentPreviewItem as? TimelineMediaPreviewItem.Media)?.id == itemID else { return } refreshCurrentPreviewItem() } @@ -301,7 +301,8 @@ private struct DownloadIndicatorView: View { private var shouldShowDownloadIndicator: Bool { switch currentItem { case .media(let mediaItem): mediaItem.fileHandle == nil - case .loading(let loadingItem): loadingItem.state == .paginating + case .loading(.paginatingBackwards), .loading(.paginatingForwards): true + case .loading: false } } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift index cdd65880be..766ade40fd 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift @@ -222,7 +222,7 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie thumbnailInfo: .mockThumbnail, contentType: contentType)) - let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreen : .mediaFilesScreen) + let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreenLive : .mediaFilesScreen) let timelineController = MockTimelineController(timelineKind: timelineKind) timelineController.timelineItems = [item] diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift index 194f79368b..8a7e104c5a 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift @@ -63,7 +63,7 @@ struct TimelineMediaPreviewRedactConfirmationView: View { .scaledFrame(size: 40) .background { LoadableImage(mediaSource: mediaSource, - mediaType: .timelineItem(uniqueID: item.id.uniqueID), + mediaType: .generic, blurhash: item.blurhash, mediaProvider: context.mediaProvider) { Color.compound.bgSubtleSecondary diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift index efcdfb6c2d..ed7e47744f 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift @@ -79,6 +79,10 @@ final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol { .store(in: &cancellables) } + func stop() { + viewModel.stop() + } + func toPresentable() -> AnyView { AnyView(MediaEventsTimelineScreen(context: viewModel.context)) } diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift index 57d1dd1d2a..24323bc2b9 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift @@ -64,6 +64,19 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType } .store(in: &cancellables) + mediaTimelineViewModel.actions.sink { [weak self] action in + switch action { + case .displayMediaPreview(let mediaPreviewViewModel): + self?.displayMediaPreview(mediaPreviewViewModel) + case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, + .displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen, + .tappedOnSenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure, + .composer, .hasScrolled, .viewInRoomTimeline: + break + } + } + .store(in: &cancellables) + filesTimelineViewModel.context.$viewState.sink { [weak self] timelineViewState in guard let self, state.bindings.screenMode == .files else { return @@ -73,6 +86,19 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType } .store(in: &cancellables) + filesTimelineViewModel.actions.sink { [weak self] action in + switch action { + case .displayMediaPreview(let mediaPreviewViewModel): + self?.displayMediaPreview(mediaPreviewViewModel) + case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, + .displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen, + .tappedOnSenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure, + .composer, .hasScrolled, .viewInRoomTimeline: + break + } + } + .store(in: &cancellables) + updateWithTimelineViewState(activeTimelineViewModel.context.viewState) } @@ -90,10 +116,15 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType case .oldestItemDidDisappear: isOldestItemVisible = false case .tappedItem(let item): - handleItemTapped(item) + activeTimelineViewModel.context.send(viewAction: .mediaTapped(itemID: item.identifier)) } } + func stop() { + // Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier. + state.bindings.mediaPreviewViewModel = nil + } + // MARK: - Private private func updateWithTimelineViewState(_ timelineViewState: TimelineViewState) { @@ -146,26 +177,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType } } - private func handleItemTapped(_ item: RoomTimelineItemViewState) { - let item: EventBasedMessageTimelineItemProtocol? = switch item.type { - case .audio(let audioItem): audioItem - case .file(let fileItem): fileItem - case .image(let imageItem): imageItem - case .video(let videoItem): videoItem - default: nil - } - - guard let item else { - MXLog.error("Unexpected item type tapped.") - return - } - - let viewModel = TimelineMediaPreviewViewModel(initialItem: item, - timelineViewModel: activeTimelineViewModel, - mediaProvider: mediaProvider, - photoLibraryManager: PhotoLibraryManager(), - userIndicatorController: userIndicatorController, - appMediator: appMediator) + private func displayMediaPreview(_ viewModel: TimelineMediaPreviewViewModel) { viewModel.actions.sink { [weak self] action in guard let self else { return } switch action { diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModelProtocol.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModelProtocol.swift index 990f97b091..e024f2a2af 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModelProtocol.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModelProtocol.swift @@ -11,4 +11,6 @@ import Combine protocol MediaEventsTimelineScreenViewModelProtocol { var actionsPublisher: AnyPublisher { get } var context: MediaEventsTimelineScreenViewModelType.Context { get } + + func stop() } diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift index 9b0a1a30f9..c7926c73b6 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift @@ -62,6 +62,9 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol { guard let self else { return } switch action { + case .viewInRoomTimeline(let itemID): + guard let eventID = itemID.eventID else { fatalError("A pinned event must have an event ID.") } + actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID)) case .dismiss: self.actionsSubject.send(.dismiss) } @@ -77,6 +80,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.displayUser(userID: userID)) case .displayMessageForwarding(let forwardingItem): actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem)) + case .displayMediaPreview(let mediaPreviewViewModel): + viewModel.displayMediaPreview(mediaPreviewViewModel) case .displayLocation(_, let geoURI, let description): actionsSubject.send(.presentLocationViewer(geoURI: geoURI, description: description)) case .viewInRoomTimeline(let eventID): @@ -91,6 +96,10 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol { } .store(in: &cancellables) } + + func stop() { + viewModel.stop() + } func toPresentable() -> AnyView { AnyView(PinnedEventsTimelineScreen(context: viewModel.context, timelineContext: timelineViewModel.context)) diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenModels.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenModels.swift index 4f75ac1016..80c5ee4d5c 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenModels.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenModels.swift @@ -8,10 +8,18 @@ import Foundation enum PinnedEventsTimelineScreenViewModelAction { + case viewInRoomTimeline(itemID: TimelineItemIdentifier) case dismiss } -struct PinnedEventsTimelineScreenViewState: BindableState { } +struct PinnedEventsTimelineScreenViewState: BindableState { + var bindings = PinnedEventsTimelineScreenViewStateBindings() +} + +struct PinnedEventsTimelineScreenViewStateBindings { + /// The view model used to present a QuickLook media preview. + var mediaPreviewViewModel: TimelineMediaPreviewViewModel? +} enum PinnedEventsTimelineScreenViewAction { case close diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenViewModel.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenViewModel.swift index 265d152009..d46f2053e0 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenViewModel.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenViewModel.swift @@ -34,4 +34,23 @@ class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelTy actionsSubject.send(.dismiss) } } + + func stop() { + // Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier. + state.bindings.mediaPreviewViewModel = nil + } + + func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel) { + mediaPreviewViewModel.actions.sink { [weak self] action in + switch action { + case .viewInRoomTimeline(let itemID): + self?.actionsSubject.send(.viewInRoomTimeline(itemID: itemID)) + case .dismiss: + self?.state.bindings.mediaPreviewViewModel = nil + } + } + .store(in: &cancellables) + + state.bindings.mediaPreviewViewModel = mediaPreviewViewModel + } } diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenViewModelProtocol.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenViewModelProtocol.swift index 53f249f040..ccf9906fd8 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenViewModelProtocol.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenViewModelProtocol.swift @@ -11,4 +11,8 @@ import Combine protocol PinnedEventsTimelineScreenViewModelProtocol { var actionsPublisher: AnyPublisher { get } var context: PinnedEventsTimelineScreenViewModelType.Context { get } + + func stop() + + func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel) } diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift index 4827f0267f..07b77ba461 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift @@ -27,7 +27,7 @@ struct PinnedEventsTimelineScreen: View { .toolbar { toolbar } .background(.compound.bgCanvasDefault) .interactiveDismissDisabled() - .interactiveQuickLook(item: $timelineContext.mediaPreviewItem) + .timelineMediaPreview(viewModel: $context.mediaPreviewViewModel) .sheet(item: $timelineContext.debugInfo) { TimelineItemDebugView(info: $0) } .sheet(item: $timelineContext.actionMenuInfo) { info in let actions = TimelineItemMenuActionProvider(timelineItem: info.item, diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 8ced08e660..7af774f779 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -126,6 +126,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.presentMediaUploadPicker(.photoLibrary)) case .displayDocumentPicker: actionsSubject.send(.presentMediaUploadPicker(.documents)) + case .displayMediaPreview(let mediaPreviewViewModel): + roomViewModel.displayMediaPreview(mediaPreviewViewModel) case .displayLocationPicker: actionsSubject.send(.presentLocationPicker) case .displayPollForm(let mode): @@ -199,7 +201,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol { func stop() { composerViewModel.saveDraft() - timelineViewModel.stop() + roomViewModel.stop() } func toPresentable() -> AnyView { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 4a5bc7c543..7943cbc2fa 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -67,7 +67,10 @@ struct RoomScreenViewState: BindableState { var bindings: RoomScreenViewStateBindings } -struct RoomScreenViewStateBindings { } +struct RoomScreenViewStateBindings { + /// The view model used to present a QuickLook media preview. + var mediaPreviewViewModel: TimelineMediaPreviewViewModel? +} enum RoomScreenFooterViewAction { case resolvePinViolation(userID: String) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 439b5e76a1..68a9519ba2 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -115,6 +115,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } } + func stop() { + // Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier. + state.bindings.mediaPreviewViewModel = nil + } + func timelineHasScrolled(direction: ScrollDirection) { state.lastScrollDirection = direction } @@ -123,6 +128,20 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol state.pinnedEventsBannerState.setSelectedPinnedEventID(eventID) } + func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel) { + mediaPreviewViewModel.actions.sink { [weak self] action in + switch action { + case .viewInRoomTimeline: + fatalError("viewInRoomTimeline should not be visible on a room preview.") + case .dismiss: + self?.state.bindings.mediaPreviewViewModel = nil + } + } + .store(in: &cancellables) + + state.bindings.mediaPreviewViewModel = mediaPreviewViewModel + } + // MARK: - Private private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher) { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift index d791c51927..c0b50248d0 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift @@ -12,6 +12,9 @@ protocol RoomScreenViewModelProtocol { var actions: AnyPublisher { get } var context: RoomScreenViewModel.Context { get } + func stop() + func timelineHasScrolled(direction: ScrollDirection) func setSelectedPinnedEventID(_ eventID: String) + func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 2b46eae281..37b1f1fef8 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -94,7 +94,7 @@ struct RoomScreen: View { ReadReceiptsSummaryView(orderedReadReceipts: $0.orderedReceipts) .environmentObject(timelineContext) } - .interactiveQuickLook(item: $timelineContext.mediaPreviewItem) + .timelineMediaPreview(viewModel: $roomContext.mediaPreviewViewModel) .track(screen: .Room) .onDrop(of: ["public.item", "public.file-url"], isTargeted: $dragOver) { providers -> Bool in guard let provider = providers.first, diff --git a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift index 64748f695d..37700868b7 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift @@ -502,7 +502,7 @@ class TimelineInteractionHandler { } func processItemTap(_ itemID: TimelineItemIdentifier) async -> TimelineControllerAction { - guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else { + guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) as? EventBasedMessageTimelineItemProtocol else { return .none } @@ -510,8 +510,14 @@ class TimelineInteractionHandler { case let item as LocationRoomTimelineItem: guard let geoURI = item.content.geoURI else { return .none } return .displayLocation(body: item.content.body, geoURI: geoURI, description: item.content.description) + case is ImageRoomTimelineItem, + is VideoRoomTimelineItem: + return await mediaPreviewAction(for: timelineItem, messageTypes: [.image, .video]) + case is AudioRoomTimelineItem, + is FileRoomTimelineItem: + return await mediaPreviewAction(for: timelineItem, messageTypes: [.audio, .file]) default: - return await displayMediaActionIfPossible(timelineItem: timelineItem) + return .none } } @@ -528,39 +534,57 @@ class TimelineInteractionHandler { } } - private func displayMediaActionIfPossible(timelineItem: RoomTimelineItemProtocol) async -> TimelineControllerAction { - var source: MediaSourceProxy? - var filename: String - var caption: String? - - switch timelineItem { - case let item as ImageRoomTimelineItem: - source = item.content.imageInfo.source - filename = item.content.filename - caption = item.content.caption - case let item as VideoRoomTimelineItem: - source = item.content.videoInfo.source - filename = item.content.filename - caption = item.content.caption - case let item as FileRoomTimelineItem: - source = item.content.source - filename = item.content.filename - caption = item.content.caption - case let item as AudioRoomTimelineItem: - // For now we are just displaying audio messages with the File preview until we create a timeline player for them. - source = item.content.source - filename = item.content.filename - caption = item.content.caption - default: - return .none + private func mediaPreviewAction(for item: EventBasedMessageTimelineItemProtocol, messageTypes: [TimelineAllowedMessageType]) async -> TimelineControllerAction { + var newTimelineFocus: TimelineFocus? + var newTimelinePresentation: TimelineKind.MediaPresentation? + switch timelineController.timelineKind { + case .live: + newTimelineFocus = .live + newTimelinePresentation = .roomScreenLive + case .detached: + guard case let .event(_, eventOrTransactionID: .eventID(eventID)) = item.id else { + MXLog.error("Unexpected event type on a detached timeline.") + return .none + } + newTimelineFocus = .eventID(eventID) + newTimelinePresentation = .roomScreenDetached + case .pinned: + newTimelineFocus = .pinned + newTimelinePresentation = .pinnedEventsScreen + case .media: + break // We don't need to create a new timeline as it is already filtered. } - - guard let source else { return .none } - switch await mediaProvider.loadFileFromSource(source, filename: filename) { - case .success(let file): - return .displayMediaFile(file: file, title: caption ?? filename) - case .failure: - return .none + + if let newTimelineFocus, let newTimelinePresentation { + let timelineItemFactory = RoomTimelineItemFactory(userID: roomProxy.ownUserID, + attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()), + stateEventStringBuilder: RoomStateEventStringBuilder(userID: roomProxy.ownUserID)) + + guard case let .success(timelineController) = await timelineControllerFactory.buildMessageFilteredTimelineController(focus: newTimelineFocus, + allowedMessageTypes: messageTypes, + presentation: newTimelinePresentation, + roomProxy: roomProxy, + timelineItemFactory: timelineItemFactory, + mediaProvider: mediaProvider) else { + MXLog.error("Failed presenting media timeline") + return .none + } + + let timelineViewModel = TimelineViewModel(roomProxy: roomProxy, + timelineController: timelineController, + mediaProvider: mediaProvider, + mediaPlayerProvider: mediaPlayerProvider, + voiceMessageMediaManager: voiceMessageMediaManager, + userIndicatorController: userIndicatorController, + appMediator: appMediator, + appSettings: appSettings, + analyticsService: analyticsService, + emojiProvider: emojiProvider, + timelineControllerFactory: timelineControllerFactory) + + return .displayMediaPreview(item: item, timelineViewModel: .new(timelineViewModel)) + } else { + return .displayMediaPreview(item: item, timelineViewModel: .active) } } } diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index 8eb315f523..3367ca8753 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -21,6 +21,7 @@ enum TimelineViewModelAction { case displayMediaUploadPreviewScreen(url: URL) case tappedOnSenderDetails(userID: String) case displayMessageForwarding(forwardingItem: MessageForwardingItem) + case displayMediaPreview(TimelineMediaPreviewViewModel) case displayLocation(body: String, geoURI: GeoURI, description: String?) case displayResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy) case composer(action: TimelineComposerAction) @@ -123,9 +124,6 @@ struct TimelineViewStateBindings { /// Key is itemID, value is the collapsed state. var reactionsCollapsed: [TimelineItemIdentifier: Bool] - /// A media item that will be previewed with QuickLook. - var mediaPreviewItem: MediaPreviewItem? - var alertInfo: AlertInfo? var debugInfo: TimelineItemDebugInfo? diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index af2bbc0fb4..1fc2c0ab69 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -23,6 +23,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { private let roomProxy: JoinedRoomProxyProtocol private let timelineController: TimelineControllerProtocol + private let mediaProvider: MediaProviderProtocol private let mediaPlayerProvider: MediaPlayerProviderProtocol private let userIndicatorController: UserIndicatorControllerProtocol private let appMediator: AppMediatorProtocol @@ -56,6 +57,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { emojiProvider: EmojiProviderProtocol, timelineControllerFactory: TimelineControllerFactoryProtocol) { self.timelineController = timelineController + self.mediaProvider = mediaProvider self.mediaPlayerProvider = mediaPlayerProvider self.roomProxy = roomProxy self.appSettings = appSettings @@ -123,11 +125,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { // MARK: - Public - func stop() { - // Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier. - state.bindings.mediaPreviewItem = nil - } - override func process(viewAction: TimelineViewAction) { switch viewAction { case .itemAppeared(let id): @@ -542,11 +539,13 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { private func handleMediaTapped(with itemID: TimelineItemIdentifier) async { state.showLoading = true let action = await timelineInteractionHandler.processItemTap(itemID) - + switch action { - case .displayMediaFile(let file, let title): + case .displayMediaPreview(let item, let timelineViewModelKind): actionsSubject.send(.composer(action: .removeFocus)) // Hide the keyboard otherwise a big white space is sometimes shown when dismissing the preview. - state.bindings.mediaPreviewItem = MediaPreviewItem(file: file, title: title) + + let mediaPreviewViewModel = makeMediaPreviewViewModel(item: item, timelineViewModelKind: timelineViewModelKind) + actionsSubject.send(.displayMediaPreview(mediaPreviewViewModel)) case .displayLocation(let body, let geoURI, let description): actionsSubject.send(.displayLocation(body: body, geoURI: geoURI, description: description)) case .none: @@ -655,6 +654,21 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { analyticsService.trackComposer(inThread: false, isEditing: isEdit, isReply: isReply, startsThread: nil) } + private func makeMediaPreviewViewModel(item: EventBasedMessageTimelineItemProtocol, + timelineViewModelKind: TimelineControllerAction.TimelineViewModelKind) -> TimelineMediaPreviewViewModel { + let timelineViewModel = switch timelineViewModelKind { + case .active: self + case .new(let newViewModel): newViewModel + } + + return TimelineMediaPreviewViewModel(initialItem: item, + timelineViewModel: timelineViewModel, + mediaProvider: mediaProvider, + photoLibraryManager: PhotoLibraryManager(), + userIndicatorController: userIndicatorController, + appMediator: appMediator) + } + // MARK: - Timeline Item Building private func buildTimelineViews(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool = false) { diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModelProtocol.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModelProtocol.swift index f38181f6ab..eecee50a25 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModelProtocol.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModelProtocol.swift @@ -13,8 +13,8 @@ import SwiftUI protocol TimelineViewModelProtocol { var actions: AnyPublisher { get } var context: TimelineViewModel.Context { get } + func process(composerAction: ComposerToolbarViewModelAction) /// Updates the timeline to show and highlight the item with the corresponding event ID. func focusOnEvent(eventID: String) async - func stop() } diff --git a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift index eec13b44ed..99519ccbde 100644 --- a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift +++ b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift @@ -38,7 +38,7 @@ struct TimelineItemMenuActionProvider { var actions: [TimelineItemMenuAction] = [] var secondaryActions: [TimelineItemMenuAction] = [] - if timelineKind == .pinned || timelineKind == .media(.mediaFilesScreen) { + if timelineKind == .pinned || timelineKind == .media(.mediaFilesScreen) || timelineKind == .media(.pinnedEventsScreen) { actions.append(.viewInRoomTimeline) } diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 873d1d2087..2aaaa133be 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -375,7 +375,6 @@ class ClientProxy: ClientProxyProtocol { } } - // swiftlint:disable:next function_parameter_count func createRoom(name: String, topic: String?, isRoomPrivate: Bool, diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 6f1953f7ba..fb642346ab 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -117,7 +117,6 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { func createDirectRoom(with userID: String, expectedRoomName: String?) async -> Result - // swiftlint:disable:next function_parameter_count func createRoom(name: String, topic: String?, isRoomPrivate: Bool, diff --git a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift index 74ee7db135..906e816caa 100644 --- a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift +++ b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift @@ -182,11 +182,27 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { } } - func messageFilteredTimeline(allowedMessageTypes: [RoomMessageEventMessageType], + func messageFilteredTimeline(focus: TimelineFocus, + allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation) async -> Result { do { - let sdkTimeline = try await room.timelineWithConfiguration(configuration: .init(focus: .live, - allowedMessageTypes: .only(types: allowedMessageTypes), + let rustFocus: MatrixRustSDK.TimelineFocus = switch focus { + case .live: .live + case .eventID(let eventID): .event(eventId: eventID, numContextEvents: 100) + case .pinned: .pinnedEvents(maxEventsToLoad: 100, maxConcurrentRequests: 10) + } + + let rustMessageTypes: [MatrixRustSDK.RoomMessageEventMessageType] = allowedMessageTypes.map { + switch $0 { + case .audio: .audio + case .file: .file + case .image: .image + case .video: .video + } + } + + let sdkTimeline = try await room.timelineWithConfiguration(configuration: .init(focus: rustFocus, + allowedMessageTypes: .only(types: rustMessageTypes), internalIdPrefix: nil, dateDividerMode: .monthly)) diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 02297ad72d..58b24f5302 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -78,7 +78,8 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol { func timelineFocusedOnEvent(eventID: String, numberOfEvents: UInt16) async -> Result - func messageFilteredTimeline(allowedMessageTypes: [RoomMessageEventMessageType], + func messageFilteredTimeline(focus: TimelineFocus, + allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation) async -> Result func enableEncryption() async -> Result diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockTimelineController.swift index d96c2edd4d..16ec81992f 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockTimelineController.swift @@ -74,8 +74,11 @@ class MockTimelineController: TimelineControllerProtocol { focusLiveCallCount += 1 callbacks.send(.isLive(true)) } - + + private(set) var paginateBackwardsCallCount = 0 func paginateBackwards(requestSize: UInt16) async -> Result { + paginateBackwardsCallCount += 1 + paginationState = PaginationState(backward: .paginating, forward: .timelineEndReached) if client == nil { @@ -85,9 +88,10 @@ class MockTimelineController: TimelineControllerProtocol { return .success(()) } + private(set) var paginateForwardsCallCount = 0 func paginateForwards(requestSize: UInt16) async -> Result { - // try? await simulateForwardPagination() - .success(()) + paginateForwardsCallCount += 1 + return .success(()) } func sendReadReceipt(for itemID: TimelineItemIdentifier) async { diff --git a/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift index 42bea6fdb1..063d373ea0 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift @@ -42,7 +42,7 @@ class TimelineController: TimelineControllerProtocol { } var timelineKind: TimelineKind { - liveTimelineProvider.kind + activeTimelineProvider.kind } init(roomProxy: JoinedRoomProxyProtocol, diff --git a/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerFactory.swift b/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerFactory.swift index 092d6706ff..f8c5f81856 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerFactory.swift @@ -36,12 +36,13 @@ struct TimelineControllerFactory: TimelineControllerFactoryProtocol { appSettings: ServiceLocator.shared.settings) } - func buildMessageFilteredTimelineController(allowedMessageTypes: [RoomMessageEventMessageType], + func buildMessageFilteredTimelineController(focus: TimelineFocus, + allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> Result { - switch await roomProxy.messageFilteredTimeline(allowedMessageTypes: allowedMessageTypes, presentation: presentation) { + switch await roomProxy.messageFilteredTimeline(focus: focus, allowedMessageTypes: allowedMessageTypes, presentation: presentation) { case .success(let timelineProxy): return .success(TimelineController(roomProxy: roomProxy, timelineProxy: timelineProxy, diff --git a/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerFactoryProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerFactoryProtocol.swift index 6d15d478a5..882b57be11 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerFactoryProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerFactoryProtocol.swift @@ -23,7 +23,8 @@ protocol TimelineControllerFactoryProtocol { timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> TimelineControllerProtocol? - func buildMessageFilteredTimelineController(allowedMessageTypes: [RoomMessageEventMessageType], + func buildMessageFilteredTimelineController(focus: TimelineFocus, + allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, diff --git a/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerProtocol.swift index 1753877c3c..c9d492faae 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerProtocol.swift @@ -16,7 +16,14 @@ enum TimelineControllerCallback { } enum TimelineControllerAction { - case displayMediaFile(file: MediaFileHandleProxy, title: String?) + enum TimelineViewModelKind { + /// Use the active timeline view model. + case active + /// Use the newly generated view model provided. + case new(TimelineViewModel) + } + + case displayMediaPreview(item: EventBasedMessageTimelineItemProtocol, timelineViewModel: TimelineViewModelKind) case displayLocation(body: String, geoURI: GeoURI, description: String?) case none } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomStateEventStringBuilder.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomStateEventStringBuilder.swift index 2d2fa3c7fd..13320fcc18 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomStateEventStringBuilder.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomStateEventStringBuilder.swift @@ -76,7 +76,6 @@ struct RoomStateEventStringBuilder { } } - // swiftlint:disable:next function_parameter_count func buildProfileChangeString(displayName: String?, previousDisplayName: String?, avatarURLString: String?, previousAvatarURLString: String?, member: String, memberIsYou: Bool) -> String? { diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index e4368abe00..ac44c04561 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -379,8 +379,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts), encryptionAuthenticity: authenticity(eventItemProxy.shieldState))) } - - // swiftlint:disable:next function_parameter_count + private func buildPollTimelineItem(_ question: String, _ pollKind: PollKind, _ maxSelections: UInt64, @@ -645,7 +644,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { return buildStateTimelineItem(for: eventItemProxy, text: text, isOutgoing: isOutgoing) } - // swiftlint:disable:next function_parameter_count private func buildStateProfileChangeTimelineItem(for eventItemProxy: EventTimelineItemProxy, displayName: String?, previousDisplayName: String?, diff --git a/ElementX/Sources/Services/Timeline/TimelineProxy.swift b/ElementX/Sources/Services/Timeline/TimelineProxy.swift index 370cc24ea5..750f3a72e1 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxy.swift @@ -591,8 +591,8 @@ final class TimelineProxy: TimelineProxyProtocol { backPaginationStatusSubject.send(.idle) forwardPaginationStatusSubject.send(.idle) case .media(let presentation): - backPaginationStatusSubject.send(.idle) - forwardPaginationStatusSubject.send(presentation == .mediaFilesScreen ? .timelineEndReached : .idle) + backPaginationStatusSubject.send(presentation == .pinnedEventsScreen ? .timelineEndReached : .idle) + forwardPaginationStatusSubject.send(presentation == .roomScreenDetached ? .idle : .timelineEndReached) case .pinned: backPaginationStatusSubject.send(.timelineEndReached) forwardPaginationStatusSubject.send(.timelineEndReached) diff --git a/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift index a175b43faf..fece308434 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift @@ -14,10 +14,20 @@ enum TimelineKind: Equatable { case detached case pinned - enum MediaPresentation { case roomScreen, mediaFilesScreen } + enum MediaPresentation { case roomScreenLive, roomScreenDetached, pinnedEventsScreen, mediaFilesScreen } case media(MediaPresentation) } +enum TimelineFocus { + case live + case eventID(String) + case pinned +} + +enum TimelineAllowedMessageType { + case audio, file, image, video +} + enum TimelineProxyError: Error { case sdkError(Error) diff --git a/UnitTests/Sources/HomeScreenRoomTests.swift b/UnitTests/Sources/HomeScreenRoomTests.swift index ae86e8d148..073bb5f000 100644 --- a/UnitTests/Sources/HomeScreenRoomTests.swift +++ b/UnitTests/Sources/HomeScreenRoomTests.swift @@ -14,7 +14,6 @@ import XCTest class HomeScreenRoomTests: XCTestCase { var roomSummary: RoomSummary! - // swiftlint:disable:next function_parameter_count func setupRoomSummary(isMarkedUnread: Bool, unreadMessagesCount: UInt, unreadMentionsCount: UInt, diff --git a/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift b/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift index f214333187..27278abcbe 100644 --- a/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift +++ b/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift @@ -23,7 +23,7 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { initialMediaViewStates = initialMediaItems.map { RoomTimelineItemViewState(item: $0, groupStyle: .single) } } - func testInitialItems() -> TimelineMediaPreviewDataSource { + func testInitialItems() throws -> TimelineMediaPreviewDataSource { // Given a data source built with the initial items. let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates, initialItem: initialMediaItems[initialItemIndex], @@ -32,12 +32,13 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { // When the preview controller displays the data. let previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - let displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media + let displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media, + "A preview item should be found.") // Then the preview controller should be showing the initial item and the data source should reflect this. XCTAssertEqual(dataSource.initialItemIndex, initialItemIndex + initialPadding, "The initial item index should be padded for the preview controller.") - XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should be the initial item.") - XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should also be the initial item.") + XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should be the initial item.") + XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should also be the initial item.") XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The initial count of preview items should be correct.") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The initial item count should be padded for the preview controller.") @@ -45,17 +46,15 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { return dataSource } - func testCurrentUpdateItem() { + func testCurrentUpdateItem() throws { // Given a data source built with the initial items. let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates, initialItem: initialMediaItems[initialItemIndex], paginationState: .initial) // When a different item is displayed. - guard let previewItem = dataSource.previewController(previewController, previewItemAt: 1 + initialPadding) as? TimelineMediaPreviewItem.Media else { - XCTFail("A preview item should be found.") - return - } + let previewItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: 1 + initialPadding) as? TimelineMediaPreviewItem.Media, + "A preview item should be found.") dataSource.updateCurrentItem(.media(previewItem)) // Then the data source should reflect the change of item. @@ -74,7 +73,7 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { func testUpdatedItems() async throws { // Given a data source built with the initial items. - let dataSource = testInitialItems() + let dataSource = try testInitialItems() // When one of the items changes but no pagination has occurred. let deferred = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: 1) { _ in true } @@ -84,9 +83,9 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { try await deferred.fulfill() let previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - let displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media - XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.") + let displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media) + XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") + XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The number of items should not change.") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The padded number of items should not change.") @@ -94,7 +93,7 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { func testPagination() async throws { // Given a data source built with the initial items. - let dataSource = testInitialItems() + let dataSource = try testInitialItems() // When more items are loaded in a back pagination. var deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true } @@ -107,9 +106,9 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") var previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - var displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media - XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.") + var displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media) + XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") + XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") // When more items are loaded in a forward pagination or sync. @@ -123,16 +122,16 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media - XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.") + displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media) + XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") + XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") } func testPaginationLimits() async throws { // Given a data source with a small amount of padding remaining. initialPadding = 2 - let dataSource = testInitialItems() + let dataSource = try testInitialItems() // When paginating backwards by more than the available padding. var deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true } @@ -146,9 +145,9 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") var previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - var displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media - XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.") + var displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media) + XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") + XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") // When paginating forwards by more than the available padding. @@ -162,12 +161,92 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") previewItemCount = dataSource.numberOfPreviewItems(in: previewController) - displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media - XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") - XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.") + displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media) + XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") + XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") } + func testEmptyTimeline() async throws { + // Given a data source built with no timeline items loaded. + let initialItem = initialMediaItems[initialItemIndex] + let dataSource = TimelineMediaPreviewDataSource(itemViewStates: [], + initialItem: initialItem, + initialPadding: initialPadding, + paginationState: .initial) + + // When the preview controller displays the data. + var previewItemCount = dataSource.numberOfPreviewItems(in: previewController) + var displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media, + "A preview item should be found.") + + // Then the preview controller should always show the initial item. + XCTAssertEqual(dataSource.previewItems.count, 1, "The initial item should be in the preview items array.") + XCTAssertEqual(previewItemCount, 1 + (2 * initialPadding), "The initial item count should be padded for the preview controller.") + XCTAssertEqual(dataSource.initialItemIndex, initialPadding, "The initial item index should be padded for the preview controller.") + + XCTAssertEqual(displayedItem.id, initialItem.id.eventOrTransactionID, "The displayed item should be the initial item.") + XCTAssertEqual(dataSource.currentMediaItemID, initialItem.id.eventOrTransactionID, "The current item should also be the initial item.") + + // When the timeline loads the initial items. + let deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true } + let loadedItems = initialMediaItems.map { RoomTimelineItemViewState(item: $0, groupStyle: .single) } + dataSource.updatePreviewItems(itemViewStates: loadedItems) + try await deferred.fulfill() + + // Then the preview controller should still show the initial item with the other items loaded around it. + previewItemCount = dataSource.numberOfPreviewItems(in: previewController) + displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media, + "A preview item should be found.") + + XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The preview items should now be loaded.") + XCTAssertEqual(previewItemCount, 1 + (2 * initialPadding), "The item count should not change as the padding will be reduced.") + XCTAssertEqual(dataSource.initialItemIndex, initialPadding, "The item index should not change.") + + XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.") + XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.") + } + + func testTimelineUpdateWithoutInitialItem() async throws { + // Given a data source built with no timeline items loaded. + let initialItem = initialMediaItems[initialItemIndex] + let dataSource = TimelineMediaPreviewDataSource(itemViewStates: [], + initialItem: initialItem, + initialPadding: initialPadding, + paginationState: .initial) + + // When the preview controller displays the data. + var previewItemCount = dataSource.numberOfPreviewItems(in: previewController) + var displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media, + "A preview item should be found.") + + // Then the preview controller should always show the initial item. + XCTAssertEqual(dataSource.previewItems.count, 1, "The initial item should be in the preview items array.") + XCTAssertEqual(previewItemCount, 1 + (2 * initialPadding), "The initial item count should be padded for the preview controller.") + XCTAssertEqual(dataSource.initialItemIndex, initialPadding, "The initial item index should be padded for the preview controller.") + + XCTAssertEqual(displayedItem.id, initialItem.id.eventOrTransactionID, "The displayed item should be the initial item.") + XCTAssertEqual(dataSource.currentMediaItemID, initialItem.id.eventOrTransactionID, "The current item should also be the initial item.") + + // When the timeline loads more items but still doesn't include the initial item. + let failure = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: 1) { _ in true } + let loadedItems = newChunk().map { RoomTimelineItemViewState(item: $0, groupStyle: .single) } + dataSource.updatePreviewItems(itemViewStates: loadedItems) + try await failure.fulfill() + + // Then the preview controller shouldn't update the available preview items. + previewItemCount = dataSource.numberOfPreviewItems(in: previewController) + displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media, + "A preview item should be found.") + + XCTAssertEqual(dataSource.previewItems.count, 1, "No new items should have been added to the array.") + XCTAssertEqual(previewItemCount, 1 + (2 * initialPadding), "The initial item count should not change.") + XCTAssertEqual(dataSource.initialItemIndex, initialPadding, "The initial item index should not change.") + + XCTAssertEqual(displayedItem.id, initialItem.id.eventOrTransactionID, "The displayed item should not change.") + XCTAssertEqual(dataSource.currentMediaItemID, initialItem.id.eventOrTransactionID, "The current item not change.") + } + // MARK: Helpers func newChunk() -> [EventBasedMessageTimelineItemProtocol] { @@ -178,7 +257,7 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase { } private extension TimelineMediaPreviewDataSource { - var currentMediaItemID: TimelineItemIdentifier? { + var currentMediaItemID: TimelineItemIdentifier.EventOrTransactionID? { switch currentItem { case .media(let mediaItem): mediaItem.id case .loading: nil diff --git a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift index 0eade742e7..35b54abae2 100644 --- a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift +++ b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift @@ -84,18 +84,22 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[0])) } - func testLoadingMoreItem() async throws { + func testLoadingMoreItems() async throws { // Given a view model with a loaded item. try await testLoadingItem() + XCTAssertEqual(timelineController.paginateBackwardsCallCount, 0) - // When swiping to a "loading more" item. + // When swiping to a "loading more" item and there are more media items to load. + timelineController.paginationState = .init(backward: .idle, forward: .timelineEndReached) + timelineController.backPaginationResponses.append(RoomTimelineItemFixtures.mediaChunk) let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded } - context.send(viewAction: .updateCurrentItem(.loading(.paginating))) + context.send(viewAction: .updateCurrentItem(.loading(.paginatingBackwards))) try await failure.fulfill() - // Then there should no longer be a media preview and no attempt should be made to load one. + // Then there should no longer be a media preview and instead of loading any media, a pagination request should be made. XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 1) - XCTAssertEqual(context.viewState.currentItem, .loading(.paginating)) + XCTAssertEqual(context.viewState.currentItem, .loading(.paginatingBackwards)) // Note: This item only changes when the preview controller handles the new items. + XCTAssertEqual(timelineController.paginateBackwardsCallCount, 1) } func testPagination() async throws { @@ -130,7 +134,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { return } - let deferred = deferFulfillment(viewModel.actions) { $0 == .viewInRoomTimeline(mediaItem.id) } + let deferred = deferFulfillment(viewModel.actions) { $0 == .viewInRoomTimeline(mediaItem.timelineItem.id) } context.send(viewAction: .menuAction(.viewInRoomTimeline, item: mediaItem)) // Then the action should be sent upwards to make this happen. diff --git a/ci_scripts/ci_common.sh b/ci_scripts/ci_common.sh index 0b07e339a5..b33579ff9e 100755 --- a/ci_scripts/ci_common.sh +++ b/ci_scripts/ci_common.sh @@ -37,9 +37,7 @@ setup_github_actions_environment() { unset HOMEBREW_NO_INSTALL_FROM_API export HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 - brew update && brew install xcodegen swiftformat git-lfs a7ex/homebrew-formulae/xcresultparser - - # brew "swiftlint" # Fails on the CI: `Target /usr/local/bin/swiftlint Target /usr/local/bin/swiftlint already exists`. Installed through https://github.com/actions/virtual-environments/blob/main/images/macos/macos-12-Readme.md#linters + brew update && brew install xcodegen swiftlint swiftformat git-lfs a7ex/homebrew-formulae/xcresultparser bundle config path vendor/bundle bundle install --jobs 4 --retry 3