Scanning states for the media events timeline screen (#5847)

* implemented the scanning states for the media events timeline screen

* add mock urls to the URL.mock...
This commit is contained in:
Mauro
2026-07-08 11:51:56 +02:00
committed by GitHub
parent 9e1d95db1c
commit 5fd4551099
28 changed files with 290 additions and 43 deletions
@@ -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,84 @@ struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
linkMetadataProvider: LinkMetadataProvider(),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
}
// MARK: Content scanning
/// 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 .mockMXCScanning: nil
case .mockMXCUnsafe: false
default: true
}
}
contentScannerService.loadScanResultFromSourceClosure = { source in
switch source.url {
case .mockMXCScanning:
// Never resolve so that the scanning state remains visible.
try? await Task.sleep(for: .seconds(3600))
return .failure(.failedScanning)
case .mockMXCUnsafe:
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: .mockMXCScanning), makeImageItem(url: .mockMXCUnsafe)])
case .files:
timelineItems.append(contentsOf: [makeFileItem(url: .mockMXCScanning), makeFileItem(url: .mockMXCUnsafe)])
}
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))
}
}
@@ -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 }
}
}
@@ -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 }
}
}
@@ -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 {
@@ -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()
}
}
@@ -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
@@ -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 }
}
}