Content Scanning View (#5831)

* implement a MediaView to delegate at the view level the usage of the content scanner service, to determine how to render the media.

* fix for a small design mismatch

* fix for greedy error state

* updated naming

* just making some screenshot that have been affected by a string change on Localazy

* pr suggestions + updated strings
This commit is contained in:
Mauro
2026-07-06 16:15:27 +02:00
committed by GitHub
parent 0a77ba6c90
commit a2c1ca06dd
95 changed files with 786 additions and 219 deletions
@@ -21,7 +21,9 @@ struct AudioRoomTimelineView: View {
formattedCaption: timelineItem.content.formattedCaption,
trailingReservedSize: timelineItem.trailingReservedSize,
shouldBoost: timelineItem.shouldBoost,
isAudioFile: true) {
isAudioFile: true,
contentScannerService: context?.contentScannerService,
mediaSource: timelineItem.content.source) {
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
}
.accessibilityLabel(L10n.commonAudio)
@@ -31,6 +33,8 @@ struct AudioRoomTimelineView: View {
struct AudioRoomTimelineView_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 {
VStack(spacing: 20) {
@@ -42,6 +46,22 @@ struct AudioRoomTimelineView_Previews: PreviewProvider, TestablePreview {
caption: "This song rocks!"))
}
.environmentObject(viewModel.context)
VStack(spacing: 20) {
AudioRoomTimelineView(timelineItem: makeItem(filename: "scanning.ogg",
fileSize: 2 * 1024 * 1024,
caption: "The audio is being scanned."))
.environmentObject(scanningViewModel.context)
.environment(\.timelineContext, scanningViewModel.context)
AudioRoomTimelineView(timelineItem: makeItem(filename: "unsafe.ogg",
fileSize: 2 * 1024 * 1024,
caption: "The audio is not safe."))
.environmentObject(unsafeViewModel.context)
.environment(\.timelineContext, unsafeViewModel.context)
}
.environmentObject(viewModel.context)
.previewDisplayName("Content Scanner")
}
static func makeItem(filename: String, fileSize: UInt, caption: String? = nil) -> AudioRoomTimelineItem {
@@ -55,7 +75,7 @@ struct AudioRoomTimelineView_Previews: PreviewProvider, TestablePreview {
caption: caption,
duration: 300,
waveform: nil,
source: nil,
source: try? MediaSourceProxy(url: .mockMXCAudio, mimeType: nil),
fileSize: fileSize,
contentType: nil))
}
@@ -0,0 +1,64 @@
//
// 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 placeholder shown in place of a media item when the content scanner wasn't able to
/// validate it. Preview and download of the content are disabled.
///
/// This view only renders the failure title and message - the critical background is applied
/// to the whole message bubble by `TimelineItemBubbledStylerView`, so that any caption and
/// reply preview are included in the critical area too.
struct ContentScanningFailureView: View {
let failure: ContentScanningFailure
var body: some View {
HStack(spacing: 8) {
CompoundIcon(\.error, size: .medium, relativeTo: .compound.bodyMDSemibold)
.foregroundStyle(.compound.iconCriticalPrimary)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.compound.bodyMDSemibold)
.foregroundStyle(.compound.textCriticalPrimary)
Text(message)
.font(.compound.bodySM)
.foregroundStyle(.compound.textSecondary)
}
}
.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 ContentScanningFailureView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack(spacing: 16) {
ContentScanningFailureView(failure: .notSafe)
ContentScanningFailureView(failure: .notFound)
}
.frame(width: 260)
.padding()
}
}
@@ -20,7 +20,9 @@ struct FileRoomTimelineView: View {
caption: timelineItem.content.caption,
formattedCaption: timelineItem.content.formattedCaption,
trailingReservedSize: timelineItem.trailingReservedSize,
shouldBoost: timelineItem.shouldBoost) {
shouldBoost: timelineItem.shouldBoost,
contentScannerService: context?.contentScannerService,
mediaSource: timelineItem.content.source) {
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
}
.accessibilityLabel(L10n.commonFile)
@@ -38,6 +40,8 @@ struct MediaFileRoomTimelineContent: View {
var trailingReservedSize: CGSize = .zero
var shouldBoost = false
var isAudioFile = false
var contentScannerService: ContentScannerServiceProtocol?
var mediaSource: MediaSourceProxy?
private var fileDescription: String {
var fileDescription = "\(filename.validatedFileExtension.uppercased())"
@@ -55,13 +59,18 @@ struct MediaFileRoomTimelineContent: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let onMediaTap {
filePreview
.onTapGesture {
onMediaTap()
}
} else {
filePreview
ContentScanningView(contentScannerService: contentScannerService,
mediaSource: mediaSource) {
if let onMediaTap {
filePreview(isScanning: false)
.onTapGesture(perform: onMediaTap)
} else {
filePreview(isScanning: false)
}
} scanningContent: {
filePreview(isScanning: true)
} unsafeContent: { failure in
ContentScanningFailureView(failure: failure)
}
if let formattedCaption {
@@ -76,7 +85,7 @@ struct MediaFileRoomTimelineContent: View {
}
}
var filePreview: some View {
func filePreview(isScanning: Bool) -> some View {
Label {
VStack(alignment: .leading, spacing: 0) {
Text(filename)
@@ -90,11 +99,18 @@ struct MediaFileRoomTimelineContent: View {
.foregroundStyle(.compound.textPrimary)
.lineLimit(2)
} icon: {
CompoundIcon(icon, size: .medium, relativeTo: .body)
.foregroundColor(.compound.iconPrimary)
.scaledPadding(6)
.background(.compound.iconOnSolidPrimary,
in: RoundedRectangle(cornerRadius: 4, style: .continuous))
Group {
if isScanning {
ProgressView()
.scaledFrame(size: CompoundIcon.Size.medium.value, relativeTo: .body)
} else {
CompoundIcon(icon, size: .medium, relativeTo: .body)
.foregroundColor(.compound.iconPrimary)
}
}
.scaledPadding(6)
.background(.compound.iconOnSolidPrimary,
in: RoundedRectangle(cornerRadius: 4, style: .continuous))
}
.labelStyle(.custom(spacing: 8, alignment: .center))
.padding(.horizontal, 4) // Add to the styler's padding of 8, as we use the default insets for the caption.
@@ -105,6 +121,8 @@ struct MediaFileRoomTimelineContent: View {
struct FileRoomTimelineView_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 {
VStack(spacing: 20.0) {
@@ -126,6 +144,21 @@ struct FileRoomTimelineView_Previews: PreviewProvider, TestablePreview {
formattedCaption: "Formatted caption"))
}
.environmentObject(viewModel.context)
VStack(spacing: 20.0) {
FileRoomTimelineView(timelineItem: makeItem(filename: "scanning.pdf",
fileSize: 3 * 1024 * 1024,
caption: "The file is being scanned."))
.environmentObject(scanningViewModel.context)
.environment(\.timelineContext, scanningViewModel.context)
FileRoomTimelineView(timelineItem: makeItem(filename: "unsafe.pdf",
fileSize: 3 * 1024 * 1024,
caption: "The file is not safe."))
.environmentObject(unsafeViewModel.context)
.environment(\.timelineContext, unsafeViewModel.context)
}
.previewDisplayName("Content Scanner")
}
static func makeItem(filename: String,
@@ -141,7 +174,7 @@ struct FileRoomTimelineView_Previews: PreviewProvider, TestablePreview {
content: .init(filename: filename,
caption: caption,
formattedCaption: formattedCaption,
source: nil,
source: try? MediaSourceProxy(url: .mockMXCFile, mimeType: nil),
fileSize: fileSize,
thumbnailSource: nil,
contentType: nil))
@@ -13,33 +13,51 @@ struct ImageRoomTimelineView: View {
@Environment(\.timelineContext) private var context
let timelineItem: ImageRoomTimelineItem
@State private var contentScanningFailure: ContentScanningFailure?
var hasMediaCaption: Bool {
timelineItem.content.caption != nil
}
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
VStack(alignment: .leading, spacing: 4) {
loadableImage
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.commonImage)
// This clip shape is distinct from the one in the styler as that one
// operates on the entire message so wouldn't round the bottom corners.
.clipShape(RoundedRectangle(cornerRadius: hasMediaCaption ? 6 : 0))
.onTapGesture {
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
}
if let attributedCaption = timelineItem.content.formattedCaption {
FormattedBodyText(attributedString: attributedCaption,
trailingReservedSize: timelineItem.trailingReservedSize,
boostFontSize: timelineItem.shouldBoost)
} else if let caption = timelineItem.content.caption {
FormattedBodyText(text: caption,
trailingReservedSize: timelineItem.trailingReservedSize,
boostFontSize: timelineItem.shouldBoost)
// The caption sits 8pts below the content scanner failure placeholder, 4pts below the media.
VStack(alignment: .leading, spacing: contentScanningFailure == nil ? 4 : 8) {
ContentScanningView(contentScannerService: context?.contentScannerService,
mediaSource: timelineItem.content.imageInfo.source) {
loadableImage
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.commonImage)
// This clip shape is distinct from the one in the styler as that one
// operates on the entire message so wouldn't round the bottom corners.
.clipShape(RoundedRectangle(cornerRadius: hasMediaCaption ? 6 : 0))
.onTapGesture {
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
}
} scanningContent: {
placeholder
.overlay { ProgressView() }
.timelineMediaFrame(imageInfo: timelineItem.content.thumbnailInfo ?? timelineItem.content.imageInfo)
} unsafeContent: { failure in
ContentScanningFailureView(failure: failure)
}
caption
}
.onPreferenceChange(ContentScanningFailurePreferenceKey.self) { contentScanningFailure = $0 }
}
}
@ViewBuilder
private var caption: some View {
if let attributedCaption = timelineItem.content.formattedCaption {
FormattedBodyText(attributedString: attributedCaption,
trailingReservedSize: timelineItem.trailingReservedSize,
boostFontSize: timelineItem.shouldBoost)
} else if let caption = timelineItem.content.caption {
FormattedBodyText(text: caption,
trailingReservedSize: timelineItem.trailingReservedSize,
boostFontSize: timelineItem.shouldBoost)
}
}
@@ -75,6 +93,8 @@ struct ImageRoomTimelineView: View {
struct ImageRoomTimelineView_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 {
ScrollView {
@@ -93,6 +113,17 @@ struct ImageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
.environment(\.timelineContext, viewModel.context)
.previewLayout(.fixed(width: 390, height: 1200))
.padding(.bottom, 20)
VStack(spacing: 20.0) {
ImageRoomTimelineView(timelineItem: makeTimelineItem())
.environmentObject(scanningViewModel.context)
.environment(\.timelineContext, scanningViewModel.context)
ImageRoomTimelineView(timelineItem: makeTimelineItem(caption: "This is an unsafe image."))
.environmentObject(unsafeViewModel.context)
.environment(\.timelineContext, unsafeViewModel.context)
}
.environmentObject(viewModel.context)
.previewDisplayName("Content Scanner")
}
private static func makeTimelineItem(caption: String? = nil, isEdited: Bool = false) -> ImageRoomTimelineItem {
@@ -15,18 +15,27 @@ struct StickerRoomTimelineView: View {
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
LoadableImage(mediaSource: timelineItem.imageInfo.source,
mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID),
blurhash: timelineItem.blurhash,
size: timelineItem.imageInfo.size,
mediaProvider: context?.mediaProvider) {
ContentScanningView(contentScannerService: context?.contentScannerService,
mediaSource: timelineItem.imageInfo.source) {
LoadableImage(mediaSource: timelineItem.imageInfo.source,
mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID),
blurhash: timelineItem.blurhash,
size: timelineItem.imageInfo.size,
mediaProvider: context?.mediaProvider) {
placeholder
}
.timelineMediaFrame(imageInfo: timelineItem.imageInfo)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(L10n.commonSticker), \(timelineItem.body)")
.onTapGesture {
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
}
} scanningContent: {
placeholder
}
.timelineMediaFrame(imageInfo: timelineItem.imageInfo)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(L10n.commonSticker), \(timelineItem.body)")
.onTapGesture {
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
.overlay { ProgressView() }
.timelineMediaFrame(imageInfo: timelineItem.imageInfo)
} unsafeContent: { failure in
ContentScanningFailureView(failure: failure)
}
}
}
@@ -40,29 +49,41 @@ struct StickerRoomTimelineView: View {
struct StickerRoomTimelineView_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 {
VStack(spacing: 20.0) {
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: .randomEvent,
body: "Some image",
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
imageInfo: .mockImage))
StickerRoomTimelineView(timelineItem: makeTimelineItem(body: "Some image"))
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: .randomEvent,
body: "Blurhashed image",
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
imageInfo: .mockImage,
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW"))
StickerRoomTimelineView(timelineItem: makeTimelineItem(body: "Blurhashed image",
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW"))
}
.environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context)
VStack(spacing: 20.0) {
StickerRoomTimelineView(timelineItem: makeTimelineItem(body: "Scanning image"))
.environmentObject(scanningViewModel.context)
.environment(\.timelineContext, scanningViewModel.context)
StickerRoomTimelineView(timelineItem: makeTimelineItem(body: "Unsafe image"))
.environmentObject(unsafeViewModel.context)
.environment(\.timelineContext, unsafeViewModel.context)
}
.environmentObject(viewModel.context)
.previewDisplayName("Content Scanner")
}
private static func makeTimelineItem(body: String, blurhash: String? = nil) -> StickerRoomTimelineItem {
StickerRoomTimelineItem(id: .randomEvent,
body: body,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
imageInfo: .mockImage,
blurhash: blurhash)
}
}
@@ -13,23 +13,35 @@ struct VideoRoomTimelineView: View {
@Environment(\.timelineContext) private var context
let timelineItem: VideoRoomTimelineItem
@State private var contentScanningFailure: ContentScanningFailure?
private var hasMediaCaption: Bool {
timelineItem.content.caption != nil
}
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
VStack(alignment: .leading, spacing: 4) {
thumbnail
.timelineMediaFrame(imageInfo: timelineItem.content.thumbnailInfo)
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.commonVideo)
// This clip shape is distinct from the one in the styler as that one
// operates on the entire message so wouldn't round the bottom corners.
.clipShape(RoundedRectangle(cornerRadius: hasMediaCaption ? 6 : 0))
.onTapGesture {
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
}
// The caption sits 8pts below the content scanner failure placeholder, 4pts below the media.
VStack(alignment: .leading, spacing: contentScanningFailure == nil ? 4 : 8) {
ContentScanningView(contentScannerService: context?.contentScannerService,
mediaSource: timelineItem.content.videoInfo.source) {
thumbnail
.timelineMediaFrame(imageInfo: timelineItem.content.thumbnailInfo)
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.commonVideo)
// This clip shape is distinct from the one in the styler as that one
// operates on the entire message so wouldn't round the bottom corners.
.clipShape(RoundedRectangle(cornerRadius: hasMediaCaption ? 6 : 0))
.onTapGesture {
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
}
} scanningContent: {
placeholder
.overlay { ProgressView() }
.timelineMediaFrame(imageInfo: timelineItem.content.thumbnailInfo)
} unsafeContent: { failure in
ContentScanningFailureView(failure: failure)
}
if let attributedCaption = timelineItem.content.formattedCaption {
FormattedBodyText(attributedString: attributedCaption,
@@ -41,6 +53,7 @@ struct VideoRoomTimelineView: View {
boostFontSize: timelineItem.shouldBoost)
}
}
.onPreferenceChange(ContentScanningFailurePreferenceKey.self) { contentScanningFailure = $0 }
}
}
@@ -83,6 +96,8 @@ struct VideoRoomTimelineView: View {
struct VideoRoomTimelineView_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 {
ScrollView {
@@ -101,6 +116,17 @@ struct VideoRoomTimelineView_Previews: PreviewProvider, TestablePreview {
.environment(\.timelineContext, viewModel.context)
.previewLayout(.fixed(width: 390, height: 975))
.padding(.bottom, 20)
VStack(spacing: 20.0) {
VideoRoomTimelineView(timelineItem: makeTimelineItem())
.environmentObject(scanningViewModel.context)
.environment(\.timelineContext, scanningViewModel.context)
VideoRoomTimelineView(timelineItem: makeTimelineItem(caption: "This is an unsafe video."))
.environmentObject(unsafeViewModel.context)
.environment(\.timelineContext, unsafeViewModel.context)
}
.environmentObject(viewModel.context)
.previewDisplayName("Content Scanner")
}
private static func makeTimelineItem(caption: String? = nil, isEdited: Bool = false) -> VideoRoomTimelineItem {
@@ -30,12 +30,25 @@ struct VoiceMessageRoomTimelineContent: View {
let playerState: AudioPlayerState
var body: some View {
VoiceMessageRoomPlaybackView(playerState: playerState,
onPlayPause: onPlaybackPlayPause,
onSeek: { onPlaybackSeek($0) },
onScrubbing: { onPlaybackScrubbing($0) },
onPlaybackSpeedChange: onPlaybackSpeedChange)
.fixedSize(horizontal: false, vertical: true)
ContentScanningView(contentScannerService: context?.contentScannerService,
mediaSource: timelineItem.content.source) {
VoiceMessageRoomPlaybackView(playerState: playerState,
onPlayPause: onPlaybackPlayPause,
onSeek: { onPlaybackSeek($0) },
onScrubbing: { onPlaybackScrubbing($0) },
onPlaybackSpeedChange: onPlaybackSpeedChange)
.fixedSize(horizontal: false, vertical: true)
} scanningContent: {
VoiceMessageRoomPlaybackView(playerState: playerState,
isScanning: true,
onPlayPause: { },
onSeek: { _ in },
onScrubbing: { _ in },
onPlaybackSpeedChange: { })
.fixedSize(horizontal: false, vertical: true)
} unsafeContent: { failure in
ContentScanningFailureView(failure: failure)
}
}
private func onPlaybackPlayPause() {
@@ -67,6 +80,8 @@ struct VoiceMessageRoomTimelineContent: View {
struct VoiceMessageRoomTimelineView_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 let timelineItemIdentifier = TimelineItemIdentifier.randomEvent
static let voiceRoomTimelineItem = VoiceMessageRoomTimelineItem(id: timelineItemIdentifier,
timestamp: .mock,
@@ -77,7 +92,7 @@ struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
content: .init(filename: "audio.ogg",
duration: 300,
waveform: EstimatedWaveform.mockWaveform,
source: nil,
source: try? MediaSourceProxy(url: .mockMXCAudio, mimeType: nil),
fileSize: nil,
contentType: nil))
@@ -89,6 +104,19 @@ struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
body.environmentObject(viewModel.context)
VStack(spacing: 20) {
VoiceMessageRoomTimelineView(timelineItem: voiceRoomTimelineItem, playerState: playerState)
.environmentObject(scanningViewModel.context)
.environment(\.timelineContext, scanningViewModel.context)
VoiceMessageRoomTimelineView(timelineItem: voiceRoomTimelineItem, playerState: playerState)
.environmentObject(unsafeViewModel.context)
.environment(\.timelineContext, unsafeViewModel.context)
}
.fixedSize(horizontal: false, vertical: true)
.environmentObject(viewModel.context)
.previewDisplayName("Content Scanner")
}
static var body: some View {