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
@@ -144,8 +144,30 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
enum TimelineMediaPreviewItem: Equatable {
case media(Media)
case contentScan(Scan)
case loading(Loading)
/// The media item this preview is for, regardless of its content scanning state.
var mediaItem: Media? {
switch self {
case .media(let mediaItem): mediaItem
case .contentScan(let scan): scan.media
case .loading: nil
}
}
/// A media item that is being processed by the content scanner, or that failed a scan
/// and therefore must not be downloaded or previewed.
struct Scan: Equatable {
enum State: Equatable {
case scanning
case failure(ContentScanningFailure)
}
let media: Media
let state: State
}
/// Wraps a media file and title to be previewed with QuickLook.
@Observable class Media: NSObject, QLPreviewItem, Identifiable {
fileprivate(set) var timelineItem: EventBasedMessageTimelineItemProtocol {
@@ -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
}
}
@@ -0,0 +1,61 @@
//
// 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
/// Shown fullscreen in place of the media preview when the media failed content scanning.
struct TimelineMediaContentScanningFailureView: View {
let failure: ContentScanningFailure
var body: some View {
VStack(spacing: 16) {
BigIcon(icon: \.errorSolid, style: .alertSolid)
VStack(spacing: 8) {
Text(title)
.font(.compound.bodyLGSemibold)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
Text(message)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
.multilineTextAlignment(.center)
}
}
.padding(.horizontal, 16)
.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 TimelineMediaContentScanningFailureView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack(spacing: 48) {
TimelineMediaContentScanningFailureView(failure: .notSafe)
TimelineMediaContentScanningFailureView(failure: .notFound)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.compound.bgCanvasDefault)
.preferredColorScheme(.dark) // The preview controller forces a dark appearance.
}
}
@@ -22,6 +22,10 @@ class TimelineMediaPreviewController: QLPreviewController {
private var barButtonTimer: Timer?
private var pageScrollViewObservation: AnyCancellable?
/// The content offset that the page scroll view rests at when showing the current item.
private var pageScrollViewRestingOffset: CGFloat = 0
private var cancellables: Set<AnyCancellable> = []
private var navigationBar: UINavigationBar? {
@@ -64,6 +68,8 @@ class TimelineMediaPreviewController: QLPreviewController {
downloadIndicatorHostingController = UIHostingController(rootView: DownloadIndicatorView(context: context))
downloadIndicatorHostingController.view.backgroundColor = .clear
downloadIndicatorHostingController.sizingOptions = .intrinsicContentSize
// Let swipes that start on the overlay reach the page scroll view underneath.
downloadIndicatorHostingController.view.isUserInteractionEnabled = false
super.init(nibName: nil, bundle: nil)
@@ -148,6 +154,8 @@ class TimelineMediaPreviewController: QLPreviewController {
navigationBar?.topItem?.titleView = headerHostingController.view
observePageScrollViewIfNeeded()
updateBarButtons()
// Ridiculous hack to undo the controller's attempt to replace our info button with the list button.
@@ -156,6 +164,8 @@ class TimelineMediaPreviewController: QLPreviewController {
// The timer is scheduled on the main run loop so it always fires on the main actor.
MainActor.assumeIsolated {
self?.updateBarButtons()
// Also re-centers the overlay once the scroll view has settled on an item.
self?.updateOverlayPosition()
}
}
}
@@ -175,6 +185,34 @@ class TimelineMediaPreviewController: QLPreviewController {
}
}
/// Makes the centered overlay (scan failure, download error/indicator) track the page scroll view
/// when swiping between items, as it would otherwise float statically above the moving pages.
private func observePageScrollViewIfNeeded() {
guard pageScrollViewObservation == nil, let pageScrollView else { return }
pageScrollViewObservation = pageScrollView.publisher(for: \.contentOffset)
.sink { [weak self] _ in
self?.updateOverlayPosition()
}
}
private func updateOverlayPosition() {
guard let pageScrollView, let overlayView = downloadIndicatorHostingController.view else { return }
let pageWidth = pageScrollView.bounds.width
guard pageWidth > 0 else { return }
// Whilst resting on an item, keep track of the offset that any swipe will start from.
if !pageScrollView.isDragging, !pageScrollView.isDecelerating {
pageScrollViewRestingOffset = pageScrollView.contentOffset.x
}
let delta = pageScrollView.contentOffset.x - pageScrollViewRestingOffset
overlayView.transform = CGAffineTransform(translationX: -delta, y: 0)
// Fade towards the midpoint so the overlay never clashes with the neighbouring item's state.
overlayView.alpha = max(0, 1 - abs(delta) / (pageWidth / 2))
}
// MARK: Item loading
private func loadCurrentItem() {
@@ -271,8 +309,7 @@ private struct HeaderView: View {
}
var body: some View {
switch currentItem {
case .media(let mediaItem):
if let mediaItem = currentItem.mediaItem {
VStack(spacing: 0) {
Text(mediaItem.sender.displayName ?? mediaItem.sender.id)
.font(.compound.bodySMSemibold)
@@ -283,7 +320,7 @@ private struct HeaderView: View {
.textCase(.uppercase)
}
.fixedSize(horizontal: true, vertical: false)
case .loading:
} else {
Text(L10n.commonLoadingMore)
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary)
@@ -299,14 +336,11 @@ private struct DetailsButton: View {
}
var isHidden: Bool {
switch currentItem {
case .media: false
case .loading: true
}
currentItem.mediaItem == nil
}
var body: some View {
if case .media(let mediaItem) = currentItem {
if let mediaItem = currentItem.mediaItem {
Button { context.send(viewAction: .showItemDetails(mediaItem)) } label: {
CompoundIcon(\.info)
}
@@ -321,7 +355,7 @@ private struct CaptionView: View {
}
var body: some View {
if case let .media(mediaItem) = currentItem, mediaItem.hasCaption {
if let mediaItem = currentItem.mediaItem, mediaItem.hasCaption {
CaptionScrollView(mediaItem: mediaItem)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
@@ -381,40 +415,62 @@ private struct DownloadIndicatorView: View {
context.viewState.currentItem
}
private var shouldShowDownloadIndicator: Bool {
var body: some View {
switch currentItem {
case .media(let mediaItem): mediaItem.fileHandle == nil
case .loading(.paginatingBackwards), .loading(.paginatingForwards): true
case .loading: false
case .media(let mediaItem):
if mediaItem.downloadError != nil {
downloadErrorView
} else if mediaItem.fileHandle == nil {
loadingIndicator(isScanning: false)
}
case .contentScan(let scan):
switch scan.state {
case .scanning:
loadingIndicator(isScanning: true)
case .failure(let failure):
TimelineMediaContentScanningFailureView(failure: failure)
}
case .loading(.paginatingBackwards), .loading(.paginatingForwards):
loadingIndicator(isScanning: false)
case .loading:
EmptyView()
}
}
var body: some View {
if case let .media(mediaItem) = currentItem, mediaItem.downloadError != nil {
VStack(spacing: 24) {
CompoundIcon(\.errorSolid, size: .custom(48), relativeTo: .compound.headingLG)
.foregroundStyle(.compound.iconCriticalPrimary)
.padding(.vertical, 24.5)
.padding(.horizontal, 28.5)
VStack(spacing: 2) {
Text(L10n.commonDownloadFailed)
.font(.compound.headingMDBold)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
Text(L10n.screenMediaBrowserDownloadErrorMessage)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
}
private var downloadErrorView: some View {
VStack(spacing: 24) {
CompoundIcon(\.errorSolid, size: .custom(48), relativeTo: .compound.headingLG)
.foregroundStyle(.compound.iconCriticalPrimary)
.padding(.vertical, 24.5)
.padding(.horizontal, 28.5)
VStack(spacing: 2) {
Text(L10n.commonDownloadFailed)
.font(.compound.headingMDBold)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
Text(L10n.screenMediaBrowserDownloadErrorMessage)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 24)
.padding(.vertical, 40)
.background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14))
} else if shouldShowDownloadIndicator {
}
.padding(.horizontal, 24)
.padding(.vertical, 40)
.background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14))
}
private func loadingIndicator(isScanning: Bool) -> some View {
VStack(spacing: 32) {
ProgressView()
.controlSize(.large)
.tint(.compound.iconPrimary)
if isScanning {
Text(L10n.contentScannerScanning)
.font(.compound.bodyLGSemibold)
.foregroundStyle(.compound.textPrimary)
}
}
}
}