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 contentScannerService: ContentScannerServiceProtocol?
private let mediaSource: MediaSourceProxy? private let mediaSource: MediaSourceProxy?
private let containerShowsFailure: Bool
private let safeContent: () -> SafeContent private let safeContent: () -> SafeContent
private let scanningContent: () -> ScanningContent private let scanningContent: () -> ScanningContent
private let unsafeContent: (ContentScanningFailure) -> UnsafeContent private let unsafeContent: (ContentScanningFailure) -> UnsafeContent
@@ -54,16 +55,22 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
/// scanning and the content is always rendered. /// scanning and the content is always rendered.
/// - mediaSource: The media source to scan. Passing `nil` renders the content, this /// - mediaSource: The media source to scan. Passing `nil` renders the content, this
/// makes it convenient to wrap items whose media source is optional. /// 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. /// - safeContent: The regular presentation of the media, including any interactions with it.
/// - scanningContent: The presentation of the media whilst it's being scanned. /// - scanningContent: The presentation of the media whilst it's being scanned.
/// - unsafeContent: The presentation of the media when it can't be shown. /// - unsafeContent: The presentation of the media when it can't be shown.
init(contentScannerService: ContentScannerServiceProtocol?, init(contentScannerService: ContentScannerServiceProtocol?,
mediaSource: MediaSourceProxy?, mediaSource: MediaSourceProxy?,
containerShowsFailure: Bool = true,
@ViewBuilder safeContent: @escaping () -> SafeContent, @ViewBuilder safeContent: @escaping () -> SafeContent,
@ViewBuilder scanningContent: @escaping () -> ScanningContent, @ViewBuilder scanningContent: @escaping () -> ScanningContent,
@ViewBuilder unsafeContent: @escaping (ContentScanningFailure) -> UnsafeContent) { @ViewBuilder unsafeContent: @escaping (ContentScanningFailure) -> UnsafeContent) {
self.contentScannerService = contentScannerService self.contentScannerService = contentScannerService
self.mediaSource = mediaSource self.mediaSource = mediaSource
self.containerShowsFailure = containerShowsFailure
self.safeContent = safeContent self.safeContent = safeContent
self.scanningContent = scanningContent self.scanningContent = scanningContent
self.unsafeContent = unsafeContent self.unsafeContent = unsafeContent
@@ -71,7 +78,7 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
var body: some View { var body: some View {
content content
.preference(key: ContentScanningFailurePreferenceKey.self, value: scanFailure) .preference(key: ContentScanningFailurePreferenceKey.self, value: containerShowsFailure ? scanFailure : nil)
.task(id: mediaSource?.url.absoluteString) { .task(id: mediaSource?.url.absoluteString) {
await scan() await scan()
} }
@@ -15,23 +15,51 @@ enum TimelineReplyViewPlacement {
} }
struct TimelineReplyView: View { struct TimelineReplyView: View {
@Environment(\.timelineContext) private var timelineContext
let placement: TimelineReplyViewPlacement let placement: TimelineReplyViewPlacement
let timelineItemReplyDetails: TimelineItemReplyDetails? let timelineItemReplyDetails: TimelineItemReplyDetails?
var maxWidth: CGFloat? var maxWidth: CGFloat?
private let backgroundShape = RoundedRectangle(cornerRadius: 8)
var body: some View { var body: some View {
content ContentScanningView(contentScannerService: timelineContext?.contentScannerService,
.fixedSize(horizontal: false, vertical: true) mediaSource: scannedMediaSource,
.frame(maxWidth: maxWidth, alignment: .leading) containerShowsFailure: false) {
.padding(4.0) content
.background { .roundedContainer(padding: 4,
ZStack { maxWidth: maxWidth,
backgroundShape.fill(.compound.bgCanvasDefault) backgroundColor: .compound.bgCanvasDefault,
backgroundShape.stroke(.compound.separatorPrimary) 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 @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 { struct TimelineReplyView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock 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 = { static let attributedStringWithMention = {
var attributedString = AttributedString("To be replaced") var attributedString = AttributedString("To be replaced")
@@ -350,5 +399,28 @@ struct TimelineReplyView_Previews: PreviewProvider, TestablePreview {
.padding() .padding()
.environmentObject(viewModel.context) .environmentObject(viewModel.context)
.previewLayout(.sizeThatFits) .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)) groupStyle: .single))
.environmentObject(unsafeViewModel.context) .environmentObject(unsafeViewModel.context)
.environment(\.timelineContext, 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) .environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context) .environment(\.timelineContext, viewModel.context)
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e39edab07bcc2e86378cd7b20ca03c57118ec04f5548a5cda729680e01ef3ee6 oid sha256:71c45fbeff45298f424939143b382268cd3353b7dbbafedb55f0f981c25fd07b
size 136868 size 202962
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f7cb8dc14692fa2817766fe8036a37b0a1d8e7a2d2fe45201cd25a2612e73a56 oid sha256:95a89caaf492918522847861d1d9d32d4e05b9fef7010a135fa845ffd7742880
size 145143 size 230860
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2a91badc1b4d5a0227464ddba3fb35e757a64365c9599e122697beb62f4361be oid sha256:91358b8fdb5a052816eba91ab27d8129f8d0098d71519a6120d37e3ff9ea5692
size 93094 size 154421
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3f583508ac5f41bb91a5000610b0419e3bb3315ef23c8262853fd887ba320678 oid sha256:d8e8c41a709a5fb7720a67a9f391372aceae7a018e1931bc5ff6855d75966829
size 103536 size 184733
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:10547353e92607e08c2fe9d60f4bea6a0a67869eaa2acde8e2ccde5e751c3d0f
size 29688
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e838d2b7eff4ce2103c6dc5fc879a18ea6cdfada662d6f6765f53ff27379462e
size 35457
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:98cfb7f8110291d2b710da446bc2fe45abb13350c1946da482f2e5a53baa7a33
size 19880
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c0af1641702a78c67524d4a9db5a7a607d166dc7829c55a52a9bd968ae278deb
size 28102