From 6bd8423675f0a166dbc090815bbc41e6d9f0b543 Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:46:14 +0200 Subject: [PATCH] Media Preview content scanning (#5851) * implemented checkSourceSafetyIfNeeded in the timeline media preview * added a subtitle to the spinner when scanning and an animated drag gesture for the error and loading states * pr suggestions * pr suggestions part 2 --- .../Sources/GeneratedAccessibilityTests.swift | 4 + ElementX.xcodeproj/project.pbxproj | 4 + .../TestablePreviewsDictionary.swift | 1 + .../TimelineMediaPreviewDataSource.swift | 22 +++ .../TimelineMediaPreviewViewModel.swift | 72 +++++++--- ...elineMediaContentScanningFailureView.swift | 61 +++++++++ .../View/TimelineMediaPreviewController.swift | 126 +++++++++++++----- .../View/MediaEventsTimelineScreen.swift | 4 +- .../Sources/GeneratedPreviewTests.swift | 7 + ...ontentScanningFailureView.iPad-en-GB-0.png | 3 + ...ntentScanningFailureView.iPad-pseudo-0.png | 3 + ...tentScanningFailureView.iPhone-en-GB-0.png | 3 + ...entScanningFailureView.iPhone-pseudo-0.png | 3 + .../TimelineMediaPreviewDataSourceTests.swift | 5 +- .../TimelineMediaPreviewViewModelTests.swift | 65 ++++++++- 15 files changed, 323 insertions(+), 60 deletions(-) create mode 100644 ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaContentScanningFailureView.swift create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaContentScanningFailureView.iPad-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaContentScanningFailureView.iPad-pseudo-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaContentScanningFailureView.iPhone-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaContentScanningFailureView.iPhone-pseudo-0.png diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index 2bf9a35c2..389abf909 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -775,6 +775,10 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "TimelineItemStyler_Previews") } + func testTimelineMediaContentScanningFailureView() async throws { + try await performAccessibilityAudit(named: "TimelineMediaContentScanningFailureView_Previews") + } + func testTimelineMediaPreviewDetailsView() async throws { try await performAccessibilityAudit(named: "TimelineMediaPreviewDetailsView_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 63866f62b..b95140f1a 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -868,6 +868,7 @@ 8B76191B9DDD1AC90A6E3A35 /* MediaFileHandleProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC1D382565A4E9CAC2F14EA /* MediaFileHandleProxy.swift */; }; 8B9C2890B6DD160A0662861E /* AudioPlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78B4E56DBFFD4A7A39D10F5 /* AudioPlaybackSpeed.swift */; }; 8BC8EF6705A78946C1F22891 /* SoftLogoutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A7D4DDEEE5D2CA0C8D63CD /* SoftLogoutScreen.swift */; }; + 8BCA60848A55E0093E920B98 /* TimelineMediaContentScanningFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7D79FA27D094146E2B5C9D /* TimelineMediaContentScanningFailureView.swift */; }; 8BD22815DC34D7F9E9C1F2FE /* PillUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C537DE821FED94D23467B6C4 /* PillUtilities.swift */; }; 8C050A8012E6078BEAEF5BC8 /* PillTextAttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913C8E13B8B602C7B6C0C4AE /* PillTextAttachmentData.swift */; }; 8C1A5ECAF895D4CAF8C4D461 /* AppActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F21ED7205048668BEB44A38 /* AppActivityView.swift */; }; @@ -2630,6 +2631,7 @@ AB26D5444A4A7E095222DE8B /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.stringsdict"; sourceTree = ""; }; AB389C38BD41EB3E47092CFB /* AccessibilityTests.xctestplan */ = {isa = PBXFileReference; path = AccessibilityTests.xctestplan; sourceTree = ""; }; AB7A66212DC7D097886BD01F /* CommonSettings+NotificationName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommonSettings+NotificationName.swift"; sourceTree = ""; }; + AB7D79FA27D094146E2B5C9D /* TimelineMediaContentScanningFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaContentScanningFailureView.swift; sourceTree = ""; }; ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModelProtocol.swift; sourceTree = ""; }; ABF84AA68B2B7584D9275769 /* VoiceMessageTrashButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageTrashButton.swift; sourceTree = ""; }; AC0275CEE9CA078B34028BDF /* AppLockScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenViewModelTests.swift; sourceTree = ""; }; @@ -4745,6 +4747,7 @@ 5EC4A8482DA110602FE6DF42 /* View */ = { isa = PBXGroup; children = ( + AB7D79FA27D094146E2B5C9D /* TimelineMediaContentScanningFailureView.swift */, 5CDE60FEE95039CCCEEEE3B0 /* TimelineMediaPreviewController.swift */, 467498BEA681758BE2F80826 /* TimelineMediaPreviewDetailsView.swift */, 30856520F3263D0E195710D7 /* TimelineMediaPreviewFileExportPicker.swift */, @@ -9173,6 +9176,7 @@ 1B88BB631F7FC45A213BB554 /* TimelineItemSender.swift in Sources */, EFBBD44C0A16F017C32D2099 /* TimelineItemStatusView.swift in Sources */, 18386B777FDA74E4B3282D4F /* TimelineItemThreadSummary.swift in Sources */, + 8BCA60848A55E0093E920B98 /* TimelineMediaContentScanningFailureView.swift in Sources */, 562EFB9AB62B38830D9AA778 /* TimelineMediaFrame.swift in Sources */, 3684AD01C5FCB7616B28F629 /* TimelineMediaPreviewController.swift in Sources */, 2A56B00B070F83E0FE571193 /* TimelineMediaPreviewDataSource.swift in Sources */, diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index 32d1e9904..8fa9042ff 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -189,6 +189,7 @@ enum TestablePreviewsDictionary { "TimelineItemMenu_Previews" : TimelineItemMenu_Previews.self, "TimelineItemSendInfoLabel_Previews" : TimelineItemSendInfoLabel_Previews.self, "TimelineItemStyler_Previews" : TimelineItemStyler_Previews.self, + "TimelineMediaContentScanningFailureView_Previews" : TimelineMediaContentScanningFailureView_Previews.self, "TimelineMediaPreviewRedactConfirmationView_Previews" : TimelineMediaPreviewRedactConfirmationView_Previews.self, "TimelineReactionView_Previews" : TimelineReactionView_Previews.self, "TimelineReadReceiptsView_Previews" : TimelineReadReceiptsView_Previews.self, diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift index fbd0f75f3..c8319dc70 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift @@ -144,8 +144,30 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource { enum TimelineMediaPreviewItem: Equatable { case media(Media) + case contentScan(Scan) case loading(Loading) + /// The media item this preview is for, regardless of its content scanning state. + var mediaItem: Media? { + switch self { + case .media(let mediaItem): mediaItem + case .contentScan(let scan): scan.media + case .loading: nil + } + } + + /// A media item that is being processed by the content scanner, or that failed a scan + /// and therefore must not be downloaded or previewed. + struct Scan: Equatable { + enum State: Equatable { + case scanning + case failure(ContentScanningFailure) + } + + let media: Media + let state: State + } + /// Wraps a media file and title to be previewed with QuickLook. @Observable class Media: NSObject, QLPreviewItem, Identifiable { fileprivate(set) var timelineItem: EventBasedMessageTimelineItemProtocol { diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift index 28f0a7276..ca77a2722 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift @@ -22,6 +22,10 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { private let userIndicatorController: UserIndicatorControllerProtocol private let appMediator: AppMediatorProtocol + private var contentScannerService: ContentScannerServiceProtocol? { + timelineViewModel.context.contentScannerService + } + private let actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() @@ -109,28 +113,65 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async { if case let .media(item) = previewItem { - item.downloadError = nil // Clear any existing error. + item.downloadError = nil // Clear any existing error so that the download is retried. } - state.dataSource.updateCurrentItem(previewItem) - rebuildCurrentItemActions() + setCurrentItem(previewItem) if case let .media(mediaItem) = previewItem { - if mediaItem.fileHandle == nil, let source = mediaItem.mediaSource { - switch await mediaProvider.loadFileFromSource(source, filename: mediaItem.filename) { - case .success(let handle): - mediaItem.fileHandle = handle - state.previewControllerDriver.send(.itemLoaded(mediaItem.id)) - case .failure(let error): - MXLog.error("Failed loading media: \(error)") - context.objectWillChange.send() // Manually trigger the SwiftUI view update. - mediaItem.downloadError = error - } + guard mediaItem.fileHandle == nil, let source = mediaItem.mediaSource else { return } + + guard await checkSourceIsSafeIfNeeded(for: mediaItem, source: source) else { return } + + switch await mediaProvider.loadFileFromSource(source, filename: mediaItem.filename) { + case .success(let handle): + mediaItem.fileHandle = handle + state.previewControllerDriver.send(.itemLoaded(mediaItem.id)) + case .failure(let error): + MXLog.error("Failed loading media: \(error)") + context.objectWillChange.send() // Manually trigger the SwiftUI view update. + mediaItem.downloadError = error } } else { paginateIfNeeded() } } + /// Scans the media when a content scanner is configured, returning whether it's safe to be downloaded + /// and previewed, reflecting the scan's progress and outcome in the current item. + private func checkSourceIsSafeIfNeeded(for mediaItem: TimelineMediaPreviewItem.Media, source: MediaSourceProxy) async -> Bool { + guard let contentScannerService else { return true } + + // Only reflect the scanning state when there's no cached verdict, so that + // scanned items don't flash the scanning indicator when they're revisited. + if contentScannerService.scanResultFromSource(source) == nil { + setCurrentItem(.contentScan(.init(media: mediaItem, state: .scanning))) + } + + switch await contentScannerService.loadScanResultFromSource(source) { + case .success(true): + finishScan(with: .media(mediaItem), for: mediaItem) + return true + case .success(false): + finishScan(with: .contentScan(.init(media: mediaItem, state: .failure(.notSafe))), for: mediaItem) + return false + case .failure: + finishScan(with: .contentScan(.init(media: mediaItem, state: .failure(.notFound))), for: mediaItem) + return false + } + } + + /// Reflects the outcome of a scan in the current item, unless the user has already swiped on to another item. + private func finishScan(with previewItem: TimelineMediaPreviewItem, for mediaItem: TimelineMediaPreviewItem.Media) { + guard state.currentItem.mediaItem === mediaItem else { return } + setCurrentItem(previewItem) + } + + private func setCurrentItem(_ previewItem: TimelineMediaPreviewItem) { + context.objectWillChange.send() // The data source is a reference type so the view needs a manual update. + state.dataSource.updateCurrentItem(previewItem) + rebuildCurrentItemActions() + } + private func paginateIfNeeded() { switch state.currentItem { case .loading(.paginatingBackwards): @@ -148,8 +189,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { private func rebuildCurrentItemActions() { let timelineContext = timelineViewModel.context - state.currentItemActions = switch state.currentItem { - case .media(let mediaItem): + state.currentItemActions = state.currentItem.mediaItem.flatMap { mediaItem in TimelineItemMenuActionProvider(timelineItem: mediaItem.timelineItem, canCurrentUserSendMessage: timelineContext.viewState.canCurrentUserSendMessage, canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf, @@ -162,8 +202,6 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { timelineKind: timelineContext.viewState.timelineKind, emojiProvider: timelineContext.viewState.emojiProvider) .makeActions() - case .loading: - nil } } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaContentScanningFailureView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaContentScanningFailureView.swift new file mode 100644 index 000000000..693f2a20c --- /dev/null +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaContentScanningFailureView.swift @@ -0,0 +1,61 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Compound +import SwiftUI + +/// Shown fullscreen in place of the media preview when the media failed content scanning. +struct TimelineMediaContentScanningFailureView: View { + let failure: ContentScanningFailure + + var body: some View { + VStack(spacing: 16) { + BigIcon(icon: \.errorSolid, style: .alertSolid) + + VStack(spacing: 8) { + Text(title) + .font(.compound.bodyLGSemibold) + .foregroundStyle(.compound.textPrimary) + .multilineTextAlignment(.center) + Text(message) + .font(.compound.bodyMD) + .foregroundStyle(.compound.textSecondary) + .multilineTextAlignment(.center) + } + } + .padding(.horizontal, 16) + .accessibilityElement(children: .combine) + } + + private var title: String { + switch failure { + case .notSafe: L10n.contentScannerUnsafeTitle + case .notFound: L10n.contentScannerNotFoundTitle + } + } + + private var message: String { + switch failure { + case .notSafe: L10n.contentScannerUnsafeMessage + case .notFound: L10n.contentScannerNotFound + } + } +} + +// MARK: - Previews + +struct TimelineMediaContentScanningFailureView_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + VStack(spacing: 48) { + TimelineMediaContentScanningFailureView(failure: .notSafe) + TimelineMediaContentScanningFailureView(failure: .notFound) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.compound.bgCanvasDefault) + .preferredColorScheme(.dark) // The preview controller forces a dark appearance. + } +} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewController.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewController.swift index 037c6e75f..060824322 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewController.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewController.swift @@ -22,6 +22,10 @@ class TimelineMediaPreviewController: QLPreviewController { private var barButtonTimer: Timer? + private var pageScrollViewObservation: AnyCancellable? + /// The content offset that the page scroll view rests at when showing the current item. + private var pageScrollViewRestingOffset: CGFloat = 0 + private var cancellables: Set = [] private var navigationBar: UINavigationBar? { @@ -64,6 +68,8 @@ class TimelineMediaPreviewController: QLPreviewController { downloadIndicatorHostingController = UIHostingController(rootView: DownloadIndicatorView(context: context)) downloadIndicatorHostingController.view.backgroundColor = .clear downloadIndicatorHostingController.sizingOptions = .intrinsicContentSize + // Let swipes that start on the overlay reach the page scroll view underneath. + downloadIndicatorHostingController.view.isUserInteractionEnabled = false super.init(nibName: nil, bundle: nil) @@ -148,6 +154,8 @@ class TimelineMediaPreviewController: QLPreviewController { navigationBar?.topItem?.titleView = headerHostingController.view + observePageScrollViewIfNeeded() + updateBarButtons() // Ridiculous hack to undo the controller's attempt to replace our info button with the list button. @@ -156,6 +164,8 @@ class TimelineMediaPreviewController: QLPreviewController { // The timer is scheduled on the main run loop so it always fires on the main actor. MainActor.assumeIsolated { self?.updateBarButtons() + // Also re-centers the overlay once the scroll view has settled on an item. + self?.updateOverlayPosition() } } } @@ -175,6 +185,34 @@ class TimelineMediaPreviewController: QLPreviewController { } } + /// Makes the centered overlay (scan failure, download error/indicator) track the page scroll view + /// when swiping between items, as it would otherwise float statically above the moving pages. + private func observePageScrollViewIfNeeded() { + guard pageScrollViewObservation == nil, let pageScrollView else { return } + + pageScrollViewObservation = pageScrollView.publisher(for: \.contentOffset) + .sink { [weak self] _ in + self?.updateOverlayPosition() + } + } + + private func updateOverlayPosition() { + guard let pageScrollView, let overlayView = downloadIndicatorHostingController.view else { return } + + let pageWidth = pageScrollView.bounds.width + guard pageWidth > 0 else { return } + + // Whilst resting on an item, keep track of the offset that any swipe will start from. + if !pageScrollView.isDragging, !pageScrollView.isDecelerating { + pageScrollViewRestingOffset = pageScrollView.contentOffset.x + } + + let delta = pageScrollView.contentOffset.x - pageScrollViewRestingOffset + overlayView.transform = CGAffineTransform(translationX: -delta, y: 0) + // Fade towards the midpoint so the overlay never clashes with the neighbouring item's state. + overlayView.alpha = max(0, 1 - abs(delta) / (pageWidth / 2)) + } + // MARK: Item loading private func loadCurrentItem() { @@ -271,8 +309,7 @@ private struct HeaderView: View { } var body: some View { - switch currentItem { - case .media(let mediaItem): + if let mediaItem = currentItem.mediaItem { VStack(spacing: 0) { Text(mediaItem.sender.displayName ?? mediaItem.sender.id) .font(.compound.bodySMSemibold) @@ -283,7 +320,7 @@ private struct HeaderView: View { .textCase(.uppercase) } .fixedSize(horizontal: true, vertical: false) - case .loading: + } else { Text(L10n.commonLoadingMore) .font(.compound.bodySMSemibold) .foregroundStyle(.compound.textPrimary) @@ -299,14 +336,11 @@ private struct DetailsButton: View { } var isHidden: Bool { - switch currentItem { - case .media: false - case .loading: true - } + currentItem.mediaItem == nil } var body: some View { - if case .media(let mediaItem) = currentItem { + if let mediaItem = currentItem.mediaItem { Button { context.send(viewAction: .showItemDetails(mediaItem)) } label: { CompoundIcon(\.info) } @@ -321,7 +355,7 @@ private struct CaptionView: View { } var body: some View { - if case let .media(mediaItem) = currentItem, mediaItem.hasCaption { + if let mediaItem = currentItem.mediaItem, mediaItem.hasCaption { CaptionScrollView(mediaItem: mediaItem) .transition(.move(edge: .bottom).combined(with: .opacity)) } @@ -381,40 +415,62 @@ private struct DownloadIndicatorView: View { context.viewState.currentItem } - private var shouldShowDownloadIndicator: Bool { + var body: some View { switch currentItem { - case .media(let mediaItem): mediaItem.fileHandle == nil - case .loading(.paginatingBackwards), .loading(.paginatingForwards): true - case .loading: false + case .media(let mediaItem): + if mediaItem.downloadError != nil { + downloadErrorView + } else if mediaItem.fileHandle == nil { + loadingIndicator(isScanning: false) + } + case .contentScan(let scan): + switch scan.state { + case .scanning: + loadingIndicator(isScanning: true) + case .failure(let failure): + TimelineMediaContentScanningFailureView(failure: failure) + } + case .loading(.paginatingBackwards), .loading(.paginatingForwards): + loadingIndicator(isScanning: false) + case .loading: + EmptyView() } } - var body: some View { - if case let .media(mediaItem) = currentItem, mediaItem.downloadError != nil { - VStack(spacing: 24) { - CompoundIcon(\.errorSolid, size: .custom(48), relativeTo: .compound.headingLG) - .foregroundStyle(.compound.iconCriticalPrimary) - .padding(.vertical, 24.5) - .padding(.horizontal, 28.5) - - VStack(spacing: 2) { - Text(L10n.commonDownloadFailed) - .font(.compound.headingMDBold) - .foregroundStyle(.compound.textPrimary) - .multilineTextAlignment(.center) - Text(L10n.screenMediaBrowserDownloadErrorMessage) - .font(.compound.bodyMD) - .foregroundStyle(.compound.textPrimary) - .multilineTextAlignment(.center) - } + private var downloadErrorView: some View { + VStack(spacing: 24) { + CompoundIcon(\.errorSolid, size: .custom(48), relativeTo: .compound.headingLG) + .foregroundStyle(.compound.iconCriticalPrimary) + .padding(.vertical, 24.5) + .padding(.horizontal, 28.5) + + VStack(spacing: 2) { + Text(L10n.commonDownloadFailed) + .font(.compound.headingMDBold) + .foregroundStyle(.compound.textPrimary) + .multilineTextAlignment(.center) + Text(L10n.screenMediaBrowserDownloadErrorMessage) + .font(.compound.bodyMD) + .foregroundStyle(.compound.textPrimary) + .multilineTextAlignment(.center) } - .padding(.horizontal, 24) - .padding(.vertical, 40) - .background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14)) - } else if shouldShowDownloadIndicator { + } + .padding(.horizontal, 24) + .padding(.vertical, 40) + .background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14)) + } + + private func loadingIndicator(isScanning: Bool) -> some View { + VStack(spacing: 32) { ProgressView() .controlSize(.large) .tint(.compound.iconPrimary) + + if isScanning { + Text(L10n.contentScannerScanning) + .font(.compound.bodyLGSemibold) + .foregroundStyle(.compound.textPrimary) + } } } } diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift index 50c19e5c7..6bb544f17 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift @@ -24,8 +24,8 @@ struct MediaEventsTimelineScreen: View { .environment(\.timelineContext, context.viewState.activeTimelineContext) .timelineMediaPreview(viewModel: $context.mediaPreviewViewModel) .sheet(item: $context.mediaPreviewSheetViewModel) { sheet in - if case let .media(media) = sheet.state.currentItem { - TimelineMediaPreviewDetailsView(item: media, + if let mediaItem = sheet.state.currentItem.mediaItem { + TimelineMediaPreviewDetailsView(item: mediaItem, context: sheet.context, preferredColorScheme: nil, sheetHeight: $sheetHeight) diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index 26dcd602a..cd1593e57 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -1355,6 +1355,13 @@ extension PreviewTests { } } + @Test + func timelineMediaContentScanningFailureView() async throws { + for (index, preview) in TimelineMediaContentScanningFailureView_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + @Test func timelineMediaPreviewDetailsView() async throws { for (index, preview) in TimelineMediaPreviewDetailsView_Previews._allPreviews.enumerated() { diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaContentScanningFailureView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaContentScanningFailureView.iPad-en-GB-0.png new file mode 100644 index 000000000..17e3c379d --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaContentScanningFailureView.iPad-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf1b86e4edbfa566a23b703948d4cc9f4b75212ac624497a9d9a0ea079db25ee +size 92105 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaContentScanningFailureView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaContentScanningFailureView.iPad-pseudo-0.png new file mode 100644 index 000000000..dd8c4dbc5 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaContentScanningFailureView.iPad-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a45537628abb7586308d35394101a45b03eee87acd22578f64112c009ebcecbd +size 99384 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaContentScanningFailureView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaContentScanningFailureView.iPhone-en-GB-0.png new file mode 100644 index 000000000..13b50c154 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaContentScanningFailureView.iPhone-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39074add3578cffa3db6b8afaee1441af54e925bf3f4cc759da6a7cf0fd25d26 +size 49471 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaContentScanningFailureView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaContentScanningFailureView.iPhone-pseudo-0.png new file mode 100644 index 000000000..22323a0e1 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMediaContentScanningFailureView.iPhone-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1fc140f356f21709f4d19d78a324fdf77e89e805e38e4f477cba5e9f228b4aa5 +size 59340 diff --git a/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift b/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift index a12e96f8b..449f6932a 100644 --- a/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift +++ b/UnitTests/Sources/TimelineMediaPreviewDataSourceTests.swift @@ -271,9 +271,6 @@ struct TimelineMediaPreviewDataSourceTests { private extension TimelineMediaPreviewDataSource { var currentMediaItemID: TimelineItemIdentifier.EventOrTransactionID? { - switch currentItem { - case .media(let mediaItem): mediaItem.id - case .loading: nil - } + currentItem.mediaItem?.id } } diff --git a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift index 1feb60006..710e4eb93 100644 --- a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift +++ b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift @@ -275,6 +275,64 @@ struct TimelineMediaPreviewViewModelTests { #expect(file.url == mediaItem.fileHandle?.url) } + @Test + mutating func safeItem() async throws { + // Given a view model with a content scanner that reports the media as safe. + setupViewModel(contentScannerService: ContentScannerServiceMock(.init(scanResult: true))) + + // When the preview controller sets the current item. + try await loadInitialItem() + + // Then the media should be downloaded as usual. + guard case .media = context.viewState.currentItem else { + Issue.record("The item should be previewable") + return + } + #expect(mediaProvider.loadFileFromSourceFilenameCalled) + } + + @Test + mutating func unsafeItem() async throws { + // Given a view model with a content scanner that reports the media as unsafe. + setupViewModel(contentScannerService: ContentScannerServiceMock(.init(scanResult: false))) + guard case let .media(mediaItem) = context.viewState.currentItem else { + Issue.record("There should be a current item") + return + } + + // When the preview controller sets the current item. + let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: .seconds(1)) { $0.isItemLoaded } + context.send(viewAction: .updateCurrentItem(.media(mediaItem))) + try await failure.fulfill() + + // Then the media must not be downloaded and the failure should be reflected in the current item. + #expect(!mediaProvider.loadFileFromSourceFilenameCalled) + #expect(context.viewState.currentItem == .contentScan(.init(media: mediaItem, state: .failure(.notSafe)))) + #expect(mediaItem.fileHandle == nil) + } + + @Test + mutating func failedScanItem() async throws { + // Given a view model with a content scanner that fails to scan the media. + let contentScannerService = ContentScannerServiceMock() + contentScannerService.loadScanResultFromSourceClosure = { _ in .failure(.failedScanning) } + setupViewModel(contentScannerService: contentScannerService) + guard case let .media(mediaItem) = context.viewState.currentItem else { + Issue.record("There should be a current item") + return + } + + // When the preview controller sets the current item. + let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: .seconds(1)) { $0.isItemLoaded } + context.send(viewAction: .updateCurrentItem(.media(mediaItem))) + try await failure.fulfill() + + // Then the media must not be downloaded and the failure should be reflected in the current item. + #expect(!mediaProvider.loadFileFromSourceFilenameCalled) + #expect(context.viewState.currentItem == .contentScan(.init(media: mediaItem, state: .failure(.notFound)))) + #expect(mediaItem.fileHandle == nil) + } + // MARK: - Helpers private func loadInitialItem() async throws { @@ -289,7 +347,9 @@ struct TimelineMediaPreviewViewModelTests { try await deferred.fulfill() } - private mutating func setupViewModel(initialItemIndex: Int = 0, photoLibraryAuthorizationDenied: Bool = false) { + private mutating func setupViewModel(initialItemIndex: Int = 0, + photoLibraryAuthorizationDenied: Bool = false, + contentScannerService: ContentScannerServiceProtocol? = nil) { let initialItems = makeItems() timelineController = TimelineControllerMock(.init(timelineKind: .media(.mediaFilesScreen), timelineItems: initialItems)) @@ -298,7 +358,8 @@ struct TimelineMediaPreviewViewModelTests { viewModel = TimelineMediaPreviewViewModel(initialItem: initialItems[initialItemIndex], timelineViewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen), - timelineController: timelineController), + timelineController: timelineController, + contentScannerService: contentScannerService), mediaProvider: mediaProvider, photoLibraryManager: photoLibraryManager, userIndicatorController: UserIndicatorControllerMock(),