Content scanner also checks for thumbnails (#5911)

* scan also thumbnails for medias

* pr suggestion
This commit is contained in:
Mauro
2026-07-22 16:43:08 +02:00
committed by GitHub
parent cff46137b9
commit 6967f99626
12 changed files with 155 additions and 17 deletions
@@ -137,17 +137,20 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
}
/// Scans the media when a content scanner is configured, returning whether it's safe to be downloaded
/// and previewed, reflecting the scan's progress and outcome in the current item.
/// and previewed, reflecting the scan's progress and outcome in the current item. Both the media and
/// its thumbnail are scanned as either being downloaded through the scanner can flag the media.
private func checkSourceIsSafeIfNeeded(for mediaItem: TimelineMediaPreviewItem.Media, source: MediaSourceProxy) async -> Bool {
guard let contentScannerService else { return true }
let sources = [source, mediaItem.thumbnailMediaSource].compactMap { $0 }
// Only reflect the scanning state when there's no cached verdict, so that
// scanned items don't flash the scanning indicator when they're revisited.
if contentScannerService.scanResultFromSource(source) == nil {
if contentScannerService.scanResultFromSources(sources) == nil {
setCurrentItem(.contentScan(.init(media: mediaItem, state: .scanning)))
}
switch await contentScannerService.loadScanResultFromSource(source) {
switch await contentScannerService.loadScanResultFromSources(sources) {
case .success(true):
finishScan(with: .media(mediaItem), for: mediaItem)
return true
@@ -25,7 +25,8 @@ struct FileMediaEventsTimelineView: View {
formattedCaption: timelineItem.content.formattedCaption,
trailingReservedSize: timelineItem.trailingReservedSize,
contentScannerService: context?.contentScannerService,
mediaSource: timelineItem.content.source)
mediaSource: timelineItem.content.source,
thumbnailSource: timelineItem.content.thumbnailSource)
.accessibilityLabel(L10n.commonFile)
.frame(maxWidth: .infinity, alignment: .leading)
.bubbleBackground(isOutgoing: timelineItem.isOutgoing,
@@ -15,7 +15,8 @@ struct ImageMediaEventsTimelineView: View {
var body: some View {
ContentScanningView(contentScannerService: context?.contentScannerService,
mediaSource: timelineItem.content.imageInfo.source) {
mediaSource: timelineItem.content.imageInfo.source,
thumbnailSource: timelineItem.content.thumbnailInfo?.source) {
Color.clear // Let the image aspect fill in place
.aspectRatio(1, contentMode: .fill)
.overlay {
@@ -15,7 +15,8 @@ struct VideoMediaEventsTimelineView: View {
var body: some View {
ContentScanningView(contentScannerService: context?.contentScannerService,
mediaSource: timelineItem.content.videoInfo.source) {
mediaSource: timelineItem.content.videoInfo.source,
thumbnailSource: timelineItem.content.thumbnailInfo?.source) {
Color.clear // Let the image aspect fill in place
.aspectRatio(1, contentMode: .fill)
.overlay {
@@ -25,6 +25,7 @@ struct TimelineReplyView: View {
var body: some View {
ContentScanningView(contentScannerService: timelineContext?.contentScannerService,
mediaSource: scannedMediaSource,
thumbnailSource: scannedThumbnailSource,
containerShowsFailure: false) {
content
.roundedContainer(padding: 4,
@@ -63,6 +64,21 @@ struct TimelineReplyView: View {
}
}
/// The thumbnail source validated alongside ``scannedMediaSource`` when the replied to message has one.
private var scannedThumbnailSource: MediaSourceProxy? {
guard case .loaded(_, _, let eventContent) = timelineItemReplyDetails,
case .message(let message) = eventContent else {
return nil
}
switch message {
case .file(let content): return content.thumbnailSource
case .image(let content): return content.thumbnailInfo?.source
case .video(let content): return content.thumbnailInfo?.source
default: return nil
}
}
@ViewBuilder
private var content: some View {
if let timelineItemReplyDetails {
@@ -22,7 +22,8 @@ struct FileRoomTimelineView: View {
trailingReservedSize: timelineItem.trailingReservedSize,
shouldBoost: timelineItem.shouldBoost,
contentScannerService: context?.contentScannerService,
mediaSource: timelineItem.content.source) {
mediaSource: timelineItem.content.source,
thumbnailSource: timelineItem.content.thumbnailSource) {
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
}
.accessibilityLabel(L10n.commonFile)
@@ -42,6 +43,7 @@ struct MediaFileRoomTimelineContent: View {
var isAudioFile = false
var contentScannerService: ContentScannerServiceProtocol?
var mediaSource: MediaSourceProxy?
var thumbnailSource: MediaSourceProxy?
private var fileDescription: String {
var fileDescription = "\(filename.validatedFileExtension.uppercased())"
@@ -60,7 +62,8 @@ struct MediaFileRoomTimelineContent: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ContentScanningView(contentScannerService: contentScannerService,
mediaSource: mediaSource) {
mediaSource: mediaSource,
thumbnailSource: thumbnailSource) {
if let onMediaTap {
filePreview(isScanning: false)
.onTapGesture(perform: onMediaTap)
@@ -24,7 +24,8 @@ struct ImageRoomTimelineView: View {
// 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) {
mediaSource: timelineItem.content.imageInfo.source,
thumbnailSource: timelineItem.content.thumbnailInfo?.source) {
loadableImage
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.commonImage)
@@ -24,7 +24,8 @@ struct VideoRoomTimelineView: View {
// 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) {
mediaSource: timelineItem.content.videoInfo.source,
thumbnailSource: timelineItem.content.thumbnailInfo?.source) {
thumbnail
.timelineMediaFrame(imageInfo: timelineItem.content.thumbnailInfo)
.accessibilityElement(children: .ignore)