Content scanning view for reply preview (#5842)

* 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

* implemented content scanning in the timeline reply view

* pr suggestions
This commit is contained in:
Mauro
2026-07-07 17:49:16 +02:00
committed by GitHub
parent 834938f585
commit 9e1d95db1c
11 changed files with 152 additions and 21 deletions
@@ -43,6 +43,7 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
private let contentScannerService: ContentScannerServiceProtocol?
private let mediaSource: MediaSourceProxy?
private let containerShowsFailure: Bool
private let safeContent: () -> SafeContent
private let scanningContent: () -> ScanningContent
private let unsafeContent: (ContentScanningFailure) -> UnsafeContent
@@ -54,16 +55,22 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
/// scanning and the content is always rendered.
/// - mediaSource: The media source to scan. Passing `nil` renders the content, this
/// makes it convenient to wrap items whose media source is optional.
/// - containerShowsFailure: Whether a failure is reported up the view hierarchy through
/// ``ContentScanningFailurePreferenceKey`` so that the surrounding container (such as the
/// message bubble) adopts the critical styling. Pass `false` when the failure should only
/// affect this view, e.g. inside a reply preview where the bubble must stay regular.
/// - safeContent: The regular presentation of the media, including any interactions with it.
/// - scanningContent: The presentation of the media whilst it's being scanned.
/// - unsafeContent: The presentation of the media when it can't be shown.
init(contentScannerService: ContentScannerServiceProtocol?,
mediaSource: MediaSourceProxy?,
containerShowsFailure: Bool = true,
@ViewBuilder safeContent: @escaping () -> SafeContent,
@ViewBuilder scanningContent: @escaping () -> ScanningContent,
@ViewBuilder unsafeContent: @escaping (ContentScanningFailure) -> UnsafeContent) {
self.contentScannerService = contentScannerService
self.mediaSource = mediaSource
self.containerShowsFailure = containerShowsFailure
self.safeContent = safeContent
self.scanningContent = scanningContent
self.unsafeContent = unsafeContent
@@ -71,7 +78,7 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
var body: some View {
content
.preference(key: ContentScanningFailurePreferenceKey.self, value: scanFailure)
.preference(key: ContentScanningFailurePreferenceKey.self, value: containerShowsFailure ? scanFailure : nil)
.task(id: mediaSource?.url.absoluteString) {
await scan()
}
@@ -15,23 +15,51 @@ enum TimelineReplyViewPlacement {
}
struct TimelineReplyView: View {
@Environment(\.timelineContext) private var timelineContext
let placement: TimelineReplyViewPlacement
let timelineItemReplyDetails: TimelineItemReplyDetails?
var maxWidth: CGFloat?
private let backgroundShape = RoundedRectangle(cornerRadius: 8)
var body: some View {
content
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: maxWidth, alignment: .leading)
.padding(4.0)
.background {
ZStack {
backgroundShape.fill(.compound.bgCanvasDefault)
backgroundShape.stroke(.compound.separatorPrimary)
}
}
ContentScanningView(contentScannerService: timelineContext?.contentScannerService,
mediaSource: scannedMediaSource,
containerShowsFailure: false) {
content
.roundedContainer(padding: 4,
maxWidth: maxWidth,
backgroundColor: .compound.bgCanvasDefault,
borderColor: .compound.separatorPrimary)
} scanningContent: {
LoadingReplyView()
.roundedContainer(padding: 4,
maxWidth: maxWidth,
backgroundColor: .compound.bgCanvasDefault,
borderColor: .compound.separatorPrimary)
} unsafeContent: { failure in
ContentScanningFailureView(failure: failure)
.roundedContainer(padding: 8,
maxWidth: maxWidth,
backgroundColor: .compound.bgCriticalSubtle,
borderColor: .compound.borderCriticalSubtle)
}
}
/// The media source validated by the content scanner when the replied to message contains media.
private var scannedMediaSource: MediaSourceProxy? {
guard case .loaded(_, _, let eventContent) = timelineItemReplyDetails,
case .message(let message) = eventContent else {
return nil
}
switch message {
case .audio(let content): return content.source
case .file(let content): return content.source
case .image(let content): return content.imageInfo.source
case .video(let content): return content.videoInfo.source
case .voice(let content): return content.source
default: return nil
}
}
@ViewBuilder
@@ -189,8 +217,29 @@ struct TimelineReplyView: View {
}
}
private extension View {
/// Styles the view as a rounded container with a background fill and a border.
func roundedContainer(padding: CGFloat = 12,
maxWidth: CGFloat? = nil,
backgroundColor: Color,
borderColor: Color) -> some View {
let backgroundShape = RoundedRectangle(cornerRadius: 8)
return fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: maxWidth, alignment: .leading)
.padding(padding)
.background {
ZStack {
backgroundShape.fill(backgroundColor)
backgroundShape.stroke(borderColor)
}
}
}
}
struct TimelineReplyView_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 attributedStringWithMention = {
var attributedString = AttributedString("To be replaced")
@@ -350,5 +399,28 @@ struct TimelineReplyView_Previews: PreviewProvider, TestablePreview {
.padding()
.environmentObject(viewModel.context)
.previewLayout(.sizeThatFits)
VStack(alignment: .leading, spacing: 20) {
imageReply
.environmentObject(scanningViewModel.context)
.environment(\.timelineContext, scanningViewModel.context)
imageReply
.environmentObject(unsafeViewModel.context)
.environment(\.timelineContext, unsafeViewModel.context)
}
.padding()
.previewLayout(.sizeThatFits)
.previewDisplayName("Content Scanner")
}
static var imageReply: TimelineReplyView {
TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.image(.init(filename: "image.jpg",
caption: "Some image",
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail)))))
}
}
@@ -479,6 +479,46 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
groupStyle: .single))
.environmentObject(unsafeViewModel.context)
.environment(\.timelineContext, unsafeViewModel.context)
// A safe message replying to an unsafe image: only the reply preview shows the failure.
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(body: "Replying to an unsafe image."),
properties: .init(replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.image(.init(filename: "amazing.jpeg",
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail)))))),
groupStyle: .single))
.environmentObject(unsafeViewModel.context)
.environment(\.timelineContext, unsafeViewModel.context)
// An unsafe file replying to an unsafe image: the whole bubble and the reply preview show the failure.
RoomTimelineItemView(viewState: .init(item: FileRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(filename: "unsafe.pdf",
caption: "Replying with an unsafe file to an unsafe image.",
formattedCaption: nil,
source: try? MediaSourceProxy(url: .mockMXCFile, mimeType: nil),
fileSize: 3 * 1024 * 1024,
thumbnailSource: nil,
contentType: nil),
properties: .init(replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.image(.init(filename: "amazing.jpeg",
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail)))))),
groupStyle: .single))
.environmentObject(unsafeViewModel.context)
.environment(\.timelineContext, unsafeViewModel.context)
}
.environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context)