Media Preview content scanning (#5851)

* implemented checkSourceSafetyIfNeeded in the timeline media preview

* added a subtitle to the spinner when scanning and an animated drag gesture for the error and loading states

* pr suggestions

* pr suggestions part 2
This commit is contained in:
Mauro
2026-07-09 18:46:14 +02:00
committed by GitHub
parent ee89f8846f
commit 6bd8423675
15 changed files with 323 additions and 60 deletions
@@ -22,6 +22,10 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
private let userIndicatorController: UserIndicatorControllerProtocol
private let appMediator: AppMediatorProtocol
private var contentScannerService: ContentScannerServiceProtocol? {
timelineViewModel.context.contentScannerService
}
private let actionsSubject: PassthroughSubject<TimelineMediaPreviewViewModelAction, Never> = .init()
var actions: AnyPublisher<TimelineMediaPreviewViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
@@ -109,28 +113,65 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async {
if case let .media(item) = previewItem {
item.downloadError = nil // Clear any existing error.
item.downloadError = nil // Clear any existing error so that the download is retried.
}
state.dataSource.updateCurrentItem(previewItem)
rebuildCurrentItemActions()
setCurrentItem(previewItem)
if case let .media(mediaItem) = previewItem {
if mediaItem.fileHandle == nil, let source = mediaItem.mediaSource {
switch await mediaProvider.loadFileFromSource(source, filename: mediaItem.filename) {
case .success(let handle):
mediaItem.fileHandle = handle
state.previewControllerDriver.send(.itemLoaded(mediaItem.id))
case .failure(let error):
MXLog.error("Failed loading media: \(error)")
context.objectWillChange.send() // Manually trigger the SwiftUI view update.
mediaItem.downloadError = error
}
guard mediaItem.fileHandle == nil, let source = mediaItem.mediaSource else { return }
guard await checkSourceIsSafeIfNeeded(for: mediaItem, source: source) else { return }
switch await mediaProvider.loadFileFromSource(source, filename: mediaItem.filename) {
case .success(let handle):
mediaItem.fileHandle = handle
state.previewControllerDriver.send(.itemLoaded(mediaItem.id))
case .failure(let error):
MXLog.error("Failed loading media: \(error)")
context.objectWillChange.send() // Manually trigger the SwiftUI view update.
mediaItem.downloadError = error
}
} else {
paginateIfNeeded()
}
}
/// 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.
private func checkSourceIsSafeIfNeeded(for mediaItem: TimelineMediaPreviewItem.Media, source: MediaSourceProxy) async -> Bool {
guard let contentScannerService else { return true }
// 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 {
setCurrentItem(.contentScan(.init(media: mediaItem, state: .scanning)))
}
switch await contentScannerService.loadScanResultFromSource(source) {
case .success(true):
finishScan(with: .media(mediaItem), for: mediaItem)
return true
case .success(false):
finishScan(with: .contentScan(.init(media: mediaItem, state: .failure(.notSafe))), for: mediaItem)
return false
case .failure:
finishScan(with: .contentScan(.init(media: mediaItem, state: .failure(.notFound))), for: mediaItem)
return false
}
}
/// Reflects the outcome of a scan in the current item, unless the user has already swiped on to another item.
private func finishScan(with previewItem: TimelineMediaPreviewItem, for mediaItem: TimelineMediaPreviewItem.Media) {
guard state.currentItem.mediaItem === mediaItem else { return }
setCurrentItem(previewItem)
}
private func setCurrentItem(_ previewItem: TimelineMediaPreviewItem) {
context.objectWillChange.send() // The data source is a reference type so the view needs a manual update.
state.dataSource.updateCurrentItem(previewItem)
rebuildCurrentItemActions()
}
private func paginateIfNeeded() {
switch state.currentItem {
case .loading(.paginatingBackwards):
@@ -148,8 +189,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
private func rebuildCurrentItemActions() {
let timelineContext = timelineViewModel.context
state.currentItemActions = switch state.currentItem {
case .media(let mediaItem):
state.currentItemActions = state.currentItem.mediaItem.flatMap { mediaItem in
TimelineItemMenuActionProvider(timelineItem: mediaItem.timelineItem,
canCurrentUserSendMessage: timelineContext.viewState.canCurrentUserSendMessage,
canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf,
@@ -162,8 +202,6 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
timelineKind: timelineContext.viewState.timelineKind,
emojiProvider: timelineContext.viewState.emojiProvider)
.makeActions()
case .loading:
nil
}
}