From b98f78c86005adc9cab46c76b651bb7afc7c79ae Mon Sep 17 00:00:00 2001 From: Mauro Romito Date: Tue, 7 Jul 2026 17:06:10 +0200 Subject: [PATCH 1/2] implemented the scanning states for the media events timeline screen --- .../Sources/GeneratedAccessibilityTests.swift | 4 + ElementX.xcodeproj/project.pbxproj | 4 + .../TestablePreviewsDictionary.swift | 1 + .../View/MediaEventsTimelineScreen.swift | 93 ++++++++++++++++++- .../AudioFileEventsTimelineView.swift | 15 ++- .../FileMediaEventsTimelineView.swift | 15 ++- .../ImageMediaEventsTimelineView.swift | 40 ++++++-- .../ScanStateMediaEventsTimelineViews.swift | 59 ++++++++++++ .../VideoMediaEventsTimelineView.swift | 25 +++-- .../VoiceMessageMediaEventsTimelineView.swift | 9 +- .../Sources/GeneratedPreviewTests.swift | 7 ++ ...imelineView.Content-Scanner-iPad-en-GB.png | 3 + ...melineView.Content-Scanner-iPad-pseudo.png | 3 + ...elineView.Content-Scanner-iPhone-en-GB.png | 3 + ...lineView.Content-Scanner-iPhone-pseudo.png | 3 + ...aEventsTimelineScreen.Files-iPad-en-GB.png | 4 +- ...EventsTimelineScreen.Files-iPad-pseudo.png | 4 +- ...ventsTimelineScreen.Files-iPhone-en-GB.png | 4 +- ...entsTimelineScreen.Files-iPhone-pseudo.png | 4 +- ...aEventsTimelineScreen.Media-iPad-en-GB.png | 4 +- ...EventsTimelineScreen.Media-iPad-pseudo.png | 4 +- ...ventsTimelineScreen.Media-iPhone-en-GB.png | 4 +- ...entsTimelineScreen.Media-iPhone-pseudo.png | 4 +- ...eMediaEventsTimelineViews.iPad-en-GB-0.png | 3 + ...MediaEventsTimelineViews.iPad-pseudo-0.png | 3 + ...ediaEventsTimelineViews.iPhone-en-GB-0.png | 3 + ...diaEventsTimelineViews.iPhone-pseudo-0.png | 3 + 27 files changed, 285 insertions(+), 43 deletions(-) create mode 100644 ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/ScanStateMediaEventsTimelineViews.swift create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/imageMediaEventsTimelineView.Content-Scanner-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/imageMediaEventsTimelineView.Content-Scanner-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/imageMediaEventsTimelineView.Content-Scanner-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/imageMediaEventsTimelineView.Content-Scanner-iPhone-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/scanStateMediaEventsTimelineViews.iPad-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/scanStateMediaEventsTimelineViews.iPad-pseudo-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/scanStateMediaEventsTimelineViews.iPhone-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/scanStateMediaEventsTimelineViews.iPhone-pseudo-0.png diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index 8b312b4ac5..2bf9a35c27 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -607,6 +607,10 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "SFNumberedListView_Previews") } + func testScanStateMediaEventsTimelineViews() async throws { + try await performAccessibilityAudit(named: "ScanStateMediaEventsTimelineViews_Previews") + } + func testSearchScreen() async throws { try await performAccessibilityAudit(named: "SearchScreen_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index edf1c25df9..b1265e02ba 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -709,6 +709,7 @@ 7434A7F02D587A920B376A9A /* LoginScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */; }; 743790BF6A5B0577EA74AF14 /* ReadMarkerRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF3D25B3EDB283B5807EADCF /* ReadMarkerRoomTimelineItem.swift */; }; 748F482FEF4E04D61C39AAD7 /* EmojiPickerScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F174A5627CDB3CAF280D1880 /* EmojiPickerScreenModels.swift */; }; + 74998B11397D89DD6C24AE33 /* ScanStateMediaEventsTimelineViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29690F342FCBEE48E17129C8 /* ScanStateMediaEventsTimelineViews.swift */; }; 74DF5BC17DE9F51E077FD457 /* LinkNewDeviceServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA257D747DD7E6FFA5C2BE2D /* LinkNewDeviceServiceMock.swift */; }; 7501442D52A65F73DF79FFD4 /* PaginationIndicatorRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */; }; 75072C529C9C363F531721F1 /* RecoveryKeyScreenHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AE97AD18893E2D8120F559D /* RecoveryKeyScreenHook.swift */; }; @@ -1907,6 +1908,7 @@ 28EA8BE9EEDBD17555141C7E /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = el; path = el.lproj/Localizable.stringsdict; sourceTree = ""; }; 2910422CB628D3B2BBE47449 /* SeparatorRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorRoomTimelineView.swift; sourceTree = ""; }; 292EEE1F71DCC205C45728F7 /* ReportRoomScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportRoomScreenCoordinator.swift; sourceTree = ""; }; + 29690F342FCBEE48E17129C8 /* ScanStateMediaEventsTimelineViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanStateMediaEventsTimelineViews.swift; sourceTree = ""; }; 29A953B6C0C431DBF4DD00B4 /* RoomSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummary.swift; sourceTree = ""; }; 2A2BB38DF61F5100B8723112 /* TimelineMediaPreviewModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewModels.swift; sourceTree = ""; }; 2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilder.swift; sourceTree = ""; }; @@ -4571,6 +4573,7 @@ FC9044BE0E4A66F5B963E834 /* AudioFileEventsTimelineView.swift */, 430C73079A84654BF46A7FF5 /* FileMediaEventsTimelineView.swift */, 1FF584D757E768EA7776A532 /* ImageMediaEventsTimelineView.swift */, + 29690F342FCBEE48E17129C8 /* ScanStateMediaEventsTimelineViews.swift */, 13F354AD441E2FD83DED89AF /* SeparatorMediaEventsTimelineView.swift */, 2B1FB56520A847DD2532D5C8 /* VideoMediaEventsTimelineView.swift */, AD3521DF92391551E02004D8 /* VoiceMessageMediaEventsTimelineView.swift */, @@ -8975,6 +8978,7 @@ 82434593648CB74121F1A821 /* RowDivider.swift in Sources */, 3F55721B5C08E8D9F1295592 /* SDKListener.swift in Sources */, 88CBF1595E39CE697928DE48 /* SFNumberedListView.swift in Sources */, + 74998B11397D89DD6C24AE33 /* ScanStateMediaEventsTimelineViews.swift in Sources */, FB595EC9C00AB32F39034055 /* SceneDelegate.swift in Sources */, 0437765FF480249486893CC7 /* ScreenTrackerViewModifier.swift in Sources */, 0BFA67AFD757EE2BA569836A /* ScrollViewAdapter.swift in Sources */, diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index 30e0d4f381..32d1e99042 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -154,6 +154,7 @@ enum TestablePreviewsDictionary { "RoomSelectionScreen_Previews" : RoomSelectionScreen_Previews.self, "RoomThreadListScreen_Previews" : RoomThreadListScreen_Previews.self, "SFNumberedListView_Previews" : SFNumberedListView_Previews.self, + "ScanStateMediaEventsTimelineViews_Previews" : ScanStateMediaEventsTimelineViews_Previews.self, "SearchScreen_Previews" : SearchScreen_Previews.self, "SecureBackupKeyBackupScreen_Previews" : SecureBackupKeyBackupScreen_Previews.self, "SecurityAndPrivacyScreen_Previews" : SecurityAndPrivacyScreen_Previews.self, diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift index d42cce220a..0e65f8aa37 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift @@ -293,25 +293,25 @@ struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview { private static func makeViewModel(empty: Bool = false, screenMode: MediaEventsTimelineScreenMode) -> MediaEventsTimelineScreenViewModel { - MediaEventsTimelineScreenViewModel(mediaTimelineViewModel: makeTimelineViewModel(empty: empty), - filesTimelineViewModel: makeTimelineViewModel(empty: empty), + MediaEventsTimelineScreenViewModel(mediaTimelineViewModel: makeTimelineViewModel(empty: empty, screenMode: .media), + filesTimelineViewModel: makeTimelineViewModel(empty: empty, screenMode: .files), initialScreenMode: screenMode, mediaProvider: MediaProviderMock(.init()), userIndicatorController: UserIndicatorControllerMock(), appMediator: AppMediatorMock()) } - private static func makeTimelineViewModel(empty: Bool) -> TimelineViewModel { + private static func makeTimelineViewModel(empty: Bool, screenMode: MediaEventsTimelineScreenMode) -> TimelineViewModel { let timelineController = if empty { TimelineControllerMock.emptyMediaGallery } else { - TimelineControllerMock.mediaGallery + makeTimelineController(screenMode: screenMode) } let appSettings = AppSettings.volatile() return TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")), timelineController: timelineController, - userSession: UserSessionMock(.init()), + userSession: UserSessionMock(.init(contentScannerService: contentScannerService)), mediaPlayerProvider: MediaPlayerProviderMock(), userIndicatorController: UserIndicatorControllerMock(), appMediator: AppMediatorMock(.init()), @@ -321,4 +321,87 @@ struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview { linkMetadataProvider: LinkMetadataProvider(), timelineControllerFactory: TimelineControllerFactoryMock(.init())) } + + // MARK: Content scanning + + private static let scanningMediaURL: URL = "mxc://matrix.org/scanning" + private static let unsafeMediaURL: URL = "mxc://matrix.org/unsafe" + + /// A content scanner that reports the dedicated sources above as being scanned/unsafe and everything else as safe. + private static let contentScannerService = { + let contentScannerService = ContentScannerServiceMock() + contentScannerService.scanResultFromSourceClosure = { source in + switch source.url { + case scanningMediaURL: nil + case unsafeMediaURL: false + default: true + } + } + contentScannerService.loadScanResultFromSourceClosure = { source in + switch source.url { + case scanningMediaURL: + // Never resolve so that the scanning state remains visible. + try? await Task.sleep(for: .seconds(3600)) + return .failure(.failedScanning) + case unsafeMediaURL: + return .success(false) + default: + return .success(true) + } + } + return contentScannerService + }() + + /// The regular gallery items followed by one that is being scanned and an unsafe one. + private static func makeTimelineController(screenMode: MediaEventsTimelineScreenMode) -> TimelineControllerMock { + var timelineItems: [RoomTimelineItemProtocol] = (0..<5).reduce([]) { partialResult, _ in + partialResult + [TimelineFixtures.separator] + TimelineFixtures.mediaChunk + } + + switch screenMode { + case .media: + timelineItems.append(contentsOf: [makeImageItem(url: scanningMediaURL), makeImageItem(url: unsafeMediaURL)]) + case .files: + timelineItems.append(contentsOf: [makeFileItem(url: scanningMediaURL), makeFileItem(url: unsafeMediaURL)]) + } + + return TimelineControllerMock(.init(timelineKind: .media(.mediaFilesScreen), timelineItems: timelineItems)) + } + + private static func makeImageItem(url: URL) -> ImageRoomTimelineItem { + guard let mediaSource = try? MediaSourceProxy(url: url, mimeType: "image/jpg") else { + fatalError("Invalid mock media source URL") + } + + return ImageRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "@bob:matrix.org"), + content: .init(filename: "image.jpg", + imageInfo: .init(source: mediaSource, width: 2730, height: 2048, mimeType: "image/jpg", fileSize: nil), + thumbnailInfo: nil, + blurhash: "KpE4oyayR5|GbHb];3j@of")) + } + + private static func makeFileItem(url: URL) -> FileRoomTimelineItem { + guard let mediaSource = try? MediaSourceProxy(url: url, mimeType: nil) else { + fatalError("Invalid mock media source URL") + } + + return FileRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "@bob:matrix.org"), + content: .init(filename: "important-document.pdf", + caption: nil, + formattedCaption: nil, + source: mediaSource, + fileSize: 3 * 1024 * 1024, + thumbnailSource: nil, + contentType: nil)) + } } diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/AudioFileEventsTimelineView.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/AudioFileEventsTimelineView.swift index 3dd467a60e..9bc282dccc 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/AudioFileEventsTimelineView.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/AudioFileEventsTimelineView.swift @@ -10,18 +10,29 @@ import Compound import SwiftUI struct AudioMediaEventsTimelineView: View { + @Environment(\.timelineContext) private var context + let timelineItem: AudioRoomTimelineItem + /// Whether the item's media failed content scanning, in which case the bubble adopts + /// the critical styling. Reported by the `ContentScanningView` through the preference key. + @State private var contentScanningFailure: ContentScanningFailure? + var body: some View { MediaFileRoomTimelineContent(filename: timelineItem.content.filename, fileSize: timelineItem.content.fileSize, caption: timelineItem.content.caption, formattedCaption: timelineItem.content.formattedCaption, trailingReservedSize: timelineItem.trailingReservedSize, - isAudioFile: true) + isAudioFile: true, + contentScannerService: context?.contentScannerService, + mediaSource: timelineItem.content.source) .accessibilityLabel(L10n.commonAudio) .frame(maxWidth: .infinity, alignment: .leading) - .bubbleBackground(isOutgoing: timelineItem.isOutgoing) + .bubbleBackground(isOutgoing: timelineItem.isOutgoing, + color: contentScanningFailure == nil ? .compound.bgSubtleSecondary : .compound.bgCriticalSubtle, + borderColor: contentScanningFailure == nil ? nil : .compound.borderCriticalSubtle) + .onPreferenceChange(ContentScanningFailurePreferenceKey.self) { contentScanningFailure = $0 } } } diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/FileMediaEventsTimelineView.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/FileMediaEventsTimelineView.swift index a55ebca8a2..c8860db8f5 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/FileMediaEventsTimelineView.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/FileMediaEventsTimelineView.swift @@ -10,17 +10,28 @@ import Compound import SwiftUI struct FileMediaEventsTimelineView: View { + @Environment(\.timelineContext) private var context + let timelineItem: FileRoomTimelineItem + /// Whether the item's media failed content scanning, in which case the bubble adopts + /// the critical styling. Reported by the `ContentScanningView` through the preference key. + @State private var contentScanningFailure: ContentScanningFailure? + var body: some View { MediaFileRoomTimelineContent(filename: timelineItem.content.filename, fileSize: timelineItem.content.fileSize, caption: timelineItem.content.caption, formattedCaption: timelineItem.content.formattedCaption, - trailingReservedSize: timelineItem.trailingReservedSize) + trailingReservedSize: timelineItem.trailingReservedSize, + contentScannerService: context?.contentScannerService, + mediaSource: timelineItem.content.source) .accessibilityLabel(L10n.commonFile) .frame(maxWidth: .infinity, alignment: .leading) - .bubbleBackground(isOutgoing: timelineItem.isOutgoing) + .bubbleBackground(isOutgoing: timelineItem.isOutgoing, + color: contentScanningFailure == nil ? .compound.bgSubtleSecondary : .compound.bgCriticalSubtle, + borderColor: contentScanningFailure == nil ? nil : .compound.borderCriticalSubtle) + .onPreferenceChange(ContentScanningFailurePreferenceKey.self) { contentScanningFailure = $0 } } } diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/ImageMediaEventsTimelineView.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/ImageMediaEventsTimelineView.swift index a8ce4b6e12..e6a357e906 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/ImageMediaEventsTimelineView.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/ImageMediaEventsTimelineView.swift @@ -14,14 +14,21 @@ struct ImageMediaEventsTimelineView: View { let timelineItem: ImageRoomTimelineItem var body: some View { - Color.clear // Let the image aspect fill in place - .aspectRatio(1, contentMode: .fill) - .overlay { - loadableImage - } - .clipped() - .accessibilityElement(children: .ignore) - .accessibilityLabel(L10n.commonImage) + ContentScanningView(contentScannerService: context?.contentScannerService, + mediaSource: timelineItem.content.imageInfo.source) { + Color.clear // Let the image aspect fill in place + .aspectRatio(1, contentMode: .fill) + .overlay { + loadableImage + } + .clipped() + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.commonImage) + } scanningContent: { + ScanningMediaEventsTimelineView() + } unsafeContent: { failure in + UnsafeMediaEventsTimelineView(failure: failure) + } } @ViewBuilder @@ -57,6 +64,9 @@ struct ImageMediaEventsTimelineView: View { struct ImageMediaEventsTimelineView_Previews: PreviewProvider, TestablePreview { static let viewModel = TimelineViewModel.mock + static let scanningViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: nil))) + static let unsafeViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: false))) + static var previews: some View { ImageMediaEventsTimelineView(timelineItem: makeTimelineItem()) .frame(width: 100, height: 100) @@ -64,6 +74,20 @@ struct ImageMediaEventsTimelineView_Previews: PreviewProvider, TestablePreview { .environment(\.timelineContext, viewModel.context) .previewLayout(.sizeThatFits) .background(.black) + + HStack(spacing: 16) { + ImageMediaEventsTimelineView(timelineItem: makeTimelineItem()) + .frame(width: 100, height: 100) + .environmentObject(scanningViewModel.context) + .environment(\.timelineContext, scanningViewModel.context) + + ImageMediaEventsTimelineView(timelineItem: makeTimelineItem()) + .frame(width: 100, height: 100) + .environmentObject(unsafeViewModel.context) + .environment(\.timelineContext, unsafeViewModel.context) + } + .previewLayout(.sizeThatFits) + .previewDisplayName("Content Scanner") } private static func makeTimelineItem() -> ImageRoomTimelineItem { diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/ScanStateMediaEventsTimelineViews.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/ScanStateMediaEventsTimelineViews.swift new file mode 100644 index 0000000000..582dc2dc40 --- /dev/null +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/ScanStateMediaEventsTimelineViews.swift @@ -0,0 +1,59 @@ +// +// 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 + +/// The grid item shown in place of a media item whilst it's being scanned by the content scanner. +struct ScanningMediaEventsTimelineView: View { + var body: some View { + Color.compound.bgSubtleSecondary + .opacity(0.3) + .aspectRatio(1, contentMode: .fill) + .overlay { + ProgressView() + } + .accessibilityLabel(L10n.commonLoading) + } +} + +/// The grid item shown in place of a media item that failed content scanning. +struct UnsafeMediaEventsTimelineView: View { + let failure: ContentScanningFailure + + var body: some View { + Color.compound.bgCriticalSubtle + .aspectRatio(1, contentMode: .fill) + .overlay { + CompoundIcon(\.error, size: .medium, relativeTo: .compound.headingLG) + .foregroundStyle(.compound.iconCriticalPrimary) + } + .accessibilityLabel(accessibilityLabel) + } + + private var accessibilityLabel: String { + switch failure { + case .notSafe: L10n.contentScannerUnsafeTitle + case .notFound: L10n.contentScannerNotFoundTitle + } + } +} + +// MARK: - Previews + +struct ScanStateMediaEventsTimelineViews_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + HStack(spacing: 16) { + ScanningMediaEventsTimelineView() + .frame(width: 100, height: 100) + + UnsafeMediaEventsTimelineView(failure: .notSafe) + .frame(width: 100, height: 100) + } + .padding() + } +} diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/VideoMediaEventsTimelineView.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/VideoMediaEventsTimelineView.swift index 0f8842ac2f..be32fa3d01 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/VideoMediaEventsTimelineView.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/VideoMediaEventsTimelineView.swift @@ -14,15 +14,22 @@ struct VideoMediaEventsTimelineView: View { let timelineItem: VideoRoomTimelineItem var body: some View { - Color.clear // Let the image aspect fill in place - .aspectRatio(1, contentMode: .fill) - .overlay { - thumbnail - } - .clipped() - .overlay(alignment: .bottom) { overlay } - .accessibilityElement(children: .ignore) - .accessibilityLabel(L10n.commonVideo) + ContentScanningView(contentScannerService: context?.contentScannerService, + mediaSource: timelineItem.content.videoInfo.source) { + Color.clear // Let the image aspect fill in place + .aspectRatio(1, contentMode: .fill) + .overlay { + thumbnail + } + .clipped() + .overlay(alignment: .bottom) { overlay } + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.commonVideo) + } scanningContent: { + ScanningMediaEventsTimelineView() + } unsafeContent: { failure in + UnsafeMediaEventsTimelineView(failure: failure) + } } @ViewBuilder diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/VoiceMessageMediaEventsTimelineView.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/VoiceMessageMediaEventsTimelineView.swift index 7ddb9da683..85ff3ea58d 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/VoiceMessageMediaEventsTimelineView.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/VoiceMessageMediaEventsTimelineView.swift @@ -13,12 +13,19 @@ struct VoiceMessageMediaEventsTimelineView: View { let timelineItem: VoiceMessageRoomTimelineItem let playerState: AudioPlayerState + /// Whether the item's media failed content scanning, in which case the bubble adopts + /// the critical styling. Reported by the `ContentScanningView` through the preference key. + @State private var contentScanningFailure: ContentScanningFailure? + var body: some View { VoiceMessageRoomTimelineContent(timelineItem: timelineItem, playerState: playerState) .accessibilityLabel(L10n.commonVoiceMessage) .frame(maxWidth: .infinity, alignment: .leading) - .bubbleBackground(isOutgoing: timelineItem.isOutgoing) + .bubbleBackground(isOutgoing: timelineItem.isOutgoing, + color: contentScanningFailure == nil ? .compound.bgSubtleSecondary : .compound.bgCriticalSubtle, + borderColor: contentScanningFailure == nil ? nil : .compound.borderCriticalSubtle) + .onPreferenceChange(ContentScanningFailurePreferenceKey.self) { contentScanningFailure = $0 } } } diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index 835cae7f67..26dcd602ac 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -1061,6 +1061,13 @@ extension PreviewTests { } } + @Test + func scanStateMediaEventsTimelineViews() async throws { + for (index, preview) in ScanStateMediaEventsTimelineViews_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + @Test func searchScreen() async throws { for (index, preview) in SearchScreen_Previews._allPreviews.enumerated() { diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/imageMediaEventsTimelineView.Content-Scanner-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/imageMediaEventsTimelineView.Content-Scanner-iPad-en-GB.png new file mode 100644 index 0000000000..ec856cbeea --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/imageMediaEventsTimelineView.Content-Scanner-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d3d62051bb7b205effa89548cfda7e4dedbe9924510b9d70276e410cf546fdb +size 14595 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/imageMediaEventsTimelineView.Content-Scanner-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/imageMediaEventsTimelineView.Content-Scanner-iPad-pseudo.png new file mode 100644 index 0000000000..ec856cbeea --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/imageMediaEventsTimelineView.Content-Scanner-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d3d62051bb7b205effa89548cfda7e4dedbe9924510b9d70276e410cf546fdb +size 14595 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/imageMediaEventsTimelineView.Content-Scanner-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/imageMediaEventsTimelineView.Content-Scanner-iPhone-en-GB.png new file mode 100644 index 0000000000..d8c7c4b40f --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/imageMediaEventsTimelineView.Content-Scanner-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aee0080586b942a8ca0029f08858af96d311df910298b7738fce024bd32cdfe3 +size 8871 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/imageMediaEventsTimelineView.Content-Scanner-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/imageMediaEventsTimelineView.Content-Scanner-iPhone-pseudo.png new file mode 100644 index 0000000000..d8c7c4b40f --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/imageMediaEventsTimelineView.Content-Scanner-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aee0080586b942a8ca0029f08858af96d311df910298b7738fce024bd32cdfe3 +size 8871 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPad-en-GB.png index 1cf1d50580..1c04846caa 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3e90b582e86741c68967b40058305db5e95b10d3a4599f2b5c65a5fb105fcbd0 -size 150128 +oid sha256:356f63709601d46ee1668e18dd2409233f7be96b9a88f7d9a18b0a9cab2e8469 +size 156418 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPad-pseudo.png index 331740c754..a55a769f96 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:096132c80bfb14de4c52af7ffb9cb37d58f8b30f390e720567ec9c25b7da11e8 -size 152142 +oid sha256:746eed66f8225346510d36ba0a7b4a2fa7b6a11758dc6cd4cdbef6662ce2f2b3 +size 164016 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPhone-en-GB.png index 0a012c8219..a39a775ff3 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f489745c43deb4048ee54bec989c0665c75f8615f4d1b0ab208f1ff024116468 -size 92949 +oid sha256:4a38781ee98ea6056d3b5514f82f37f14da5bedeb914a8b065806f5a7ee95cfd +size 100047 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPhone-pseudo.png index d8d336b434..c0efec7f4a 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Files-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:91255b5b9e89cd0f6a9203f6cb80e4ee1e4f0009faf66d88f48d5bf4c3f0fdbf -size 93866 +oid sha256:11c5f04a623ec21cf870c6101313d24f749f535b55d4f8221413fdb407b6405c +size 108263 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPad-en-GB.png index 337458fbe0..96ce3130a3 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6b975317bdc59a2f0af2229ccae70fc1dae38728c96e3684ee0589bb77fc8958 -size 741520 +oid sha256:8570e21cc95a6121da7f1805c78dedd78ea9df082f60c0561bf767d0f4a0bfa3 +size 746399 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPad-pseudo.png index 20a5382c29..b8f3676629 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:850349ee2618124599f6fbaae55c85817b0fe2a69e474cdcdab7306478f6dfc0 -size 746831 +oid sha256:884c2061bab04d274b44296c24118b634be4c52c47c099b474870df565109a54 +size 751705 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPhone-en-GB.png index ab7afe9b0e..5541e28cb8 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:66d47a7c36143fee8107039d1b730951b8deb6b34b71ae07b476f209e800a99f -size 818188 +oid sha256:b1fe7781fd4623024860b9d98e7f1810a8f0ac9ae3bb58a53a76352029016f67 +size 822595 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPhone-pseudo.png index e52948f546..e57512734a 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/mediaEventsTimelineScreen.Media-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:63b3690fa27205a250c3418eda1fc16ce607e5502680ee2f523a47471b0c609f -size 823035 +oid sha256:f918cd8bc7462364c993b55e3af42c305b01e94e27a4edd44ec0e44acbf8e19c +size 827220 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/scanStateMediaEventsTimelineViews.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/scanStateMediaEventsTimelineViews.iPad-en-GB-0.png new file mode 100644 index 0000000000..d9c3274058 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/scanStateMediaEventsTimelineViews.iPad-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8de3a53e98084c267c3496082d6256d768a18de61479c9ad11cd26a121520e54 +size 72507 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/scanStateMediaEventsTimelineViews.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/scanStateMediaEventsTimelineViews.iPad-pseudo-0.png new file mode 100644 index 0000000000..d9c3274058 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/scanStateMediaEventsTimelineViews.iPad-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8de3a53e98084c267c3496082d6256d768a18de61479c9ad11cd26a121520e54 +size 72507 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/scanStateMediaEventsTimelineViews.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/scanStateMediaEventsTimelineViews.iPhone-en-GB-0.png new file mode 100644 index 0000000000..3fb019d2c2 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/scanStateMediaEventsTimelineViews.iPhone-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c54aaf1a569da2aed4ec79ac46c030b10722aa3d0ff4d41f49bb19dc3f06e91 +size 31491 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/scanStateMediaEventsTimelineViews.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/scanStateMediaEventsTimelineViews.iPhone-pseudo-0.png new file mode 100644 index 0000000000..3fb019d2c2 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/scanStateMediaEventsTimelineViews.iPhone-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c54aaf1a569da2aed4ec79ac46c030b10722aa3d0ff4d41f49bb19dc3f06e91 +size 31491 From b943344af87937fd84e0d6aa888aed1bca24171d Mon Sep 17 00:00:00 2001 From: Mauro Romito Date: Wed, 8 Jul 2026 11:49:56 +0200 Subject: [PATCH 2/2] add mock urls to the URL.mock... --- ElementX/Sources/Other/Extensions/URL.swift | 8 ++++++++ .../View/MediaEventsTimelineScreen.swift | 17 +++++++---------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/ElementX/Sources/Other/Extensions/URL.swift b/ElementX/Sources/Other/Extensions/URL.swift index fbce0b8ae9..d776a374dc 100644 --- a/ElementX/Sources/Other/Extensions/URL.swift +++ b/ElementX/Sources/Other/Extensions/URL.swift @@ -158,6 +158,14 @@ nonisolated extension URL { nonisolated static var mockMXCUserAvatar: URL { "mxc://matrix.org/1234567890AvAtArUsEr" } + + nonisolated static var mockMXCScanning: URL { + "mxc://matrix.org/1234567890ScAnNiNg" + } + + nonisolated static var mockMXCUnsafe: URL { + "mxc://matrix.org/1234567890UnSaFe" + } } // MARK: - Helpers diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift index 0e65f8aa37..6ec019d8f5 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift @@ -324,26 +324,23 @@ struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview { // MARK: Content scanning - private static let scanningMediaURL: URL = "mxc://matrix.org/scanning" - private static let unsafeMediaURL: URL = "mxc://matrix.org/unsafe" - - /// A content scanner that reports the dedicated sources above as being scanned/unsafe and everything else as safe. + /// A content scanner that reports the dedicated mock sources as being scanned/unsafe and everything else as safe. private static let contentScannerService = { let contentScannerService = ContentScannerServiceMock() contentScannerService.scanResultFromSourceClosure = { source in switch source.url { - case scanningMediaURL: nil - case unsafeMediaURL: false + case .mockMXCScanning: nil + case .mockMXCUnsafe: false default: true } } contentScannerService.loadScanResultFromSourceClosure = { source in switch source.url { - case scanningMediaURL: + case .mockMXCScanning: // Never resolve so that the scanning state remains visible. try? await Task.sleep(for: .seconds(3600)) return .failure(.failedScanning) - case unsafeMediaURL: + case .mockMXCUnsafe: return .success(false) default: return .success(true) @@ -360,9 +357,9 @@ struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview { switch screenMode { case .media: - timelineItems.append(contentsOf: [makeImageItem(url: scanningMediaURL), makeImageItem(url: unsafeMediaURL)]) + timelineItems.append(contentsOf: [makeImageItem(url: .mockMXCScanning), makeImageItem(url: .mockMXCUnsafe)]) case .files: - timelineItems.append(contentsOf: [makeFileItem(url: scanningMediaURL), makeFileItem(url: unsafeMediaURL)]) + timelineItems.append(contentsOf: [makeFileItem(url: .mockMXCScanning), makeFileItem(url: .mockMXCUnsafe)]) } return TimelineControllerMock(.init(timelineKind: .media(.mediaFilesScreen), timelineItems: timelineItems))