mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
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:
@@ -775,6 +775,10 @@ extension AccessibilityTests {
|
||||
try await performAccessibilityAudit(named: "TimelineItemStyler_Previews")
|
||||
}
|
||||
|
||||
func testTimelineMediaContentScanningFailureView() async throws {
|
||||
try await performAccessibilityAudit(named: "TimelineMediaContentScanningFailureView_Previews")
|
||||
}
|
||||
|
||||
func testTimelineMediaPreviewDetailsView() async throws {
|
||||
try await performAccessibilityAudit(named: "TimelineMediaPreviewDetailsView_Previews")
|
||||
}
|
||||
|
||||
@@ -868,6 +868,7 @@
|
||||
8B76191B9DDD1AC90A6E3A35 /* MediaFileHandleProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC1D382565A4E9CAC2F14EA /* MediaFileHandleProxy.swift */; };
|
||||
8B9C2890B6DD160A0662861E /* AudioPlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78B4E56DBFFD4A7A39D10F5 /* AudioPlaybackSpeed.swift */; };
|
||||
8BC8EF6705A78946C1F22891 /* SoftLogoutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A7D4DDEEE5D2CA0C8D63CD /* SoftLogoutScreen.swift */; };
|
||||
8BCA60848A55E0093E920B98 /* TimelineMediaContentScanningFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7D79FA27D094146E2B5C9D /* TimelineMediaContentScanningFailureView.swift */; };
|
||||
8BD22815DC34D7F9E9C1F2FE /* PillUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C537DE821FED94D23467B6C4 /* PillUtilities.swift */; };
|
||||
8C050A8012E6078BEAEF5BC8 /* PillTextAttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913C8E13B8B602C7B6C0C4AE /* PillTextAttachmentData.swift */; };
|
||||
8C1A5ECAF895D4CAF8C4D461 /* AppActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F21ED7205048668BEB44A38 /* AppActivityView.swift */; };
|
||||
@@ -2630,6 +2631,7 @@
|
||||
AB26D5444A4A7E095222DE8B /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.stringsdict"; sourceTree = "<group>"; };
|
||||
AB389C38BD41EB3E47092CFB /* AccessibilityTests.xctestplan */ = {isa = PBXFileReference; path = AccessibilityTests.xctestplan; sourceTree = "<group>"; };
|
||||
AB7A66212DC7D097886BD01F /* CommonSettings+NotificationName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommonSettings+NotificationName.swift"; sourceTree = "<group>"; };
|
||||
AB7D79FA27D094146E2B5C9D /* TimelineMediaContentScanningFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaContentScanningFailureView.swift; sourceTree = "<group>"; };
|
||||
ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
ABF84AA68B2B7584D9275769 /* VoiceMessageTrashButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageTrashButton.swift; sourceTree = "<group>"; };
|
||||
AC0275CEE9CA078B34028BDF /* AppLockScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenViewModelTests.swift; sourceTree = "<group>"; };
|
||||
@@ -4745,6 +4747,7 @@
|
||||
5EC4A8482DA110602FE6DF42 /* View */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AB7D79FA27D094146E2B5C9D /* TimelineMediaContentScanningFailureView.swift */,
|
||||
5CDE60FEE95039CCCEEEE3B0 /* TimelineMediaPreviewController.swift */,
|
||||
467498BEA681758BE2F80826 /* TimelineMediaPreviewDetailsView.swift */,
|
||||
30856520F3263D0E195710D7 /* TimelineMediaPreviewFileExportPicker.swift */,
|
||||
@@ -9173,6 +9176,7 @@
|
||||
1B88BB631F7FC45A213BB554 /* TimelineItemSender.swift in Sources */,
|
||||
EFBBD44C0A16F017C32D2099 /* TimelineItemStatusView.swift in Sources */,
|
||||
18386B777FDA74E4B3282D4F /* TimelineItemThreadSummary.swift in Sources */,
|
||||
8BCA60848A55E0093E920B98 /* TimelineMediaContentScanningFailureView.swift in Sources */,
|
||||
562EFB9AB62B38830D9AA778 /* TimelineMediaFrame.swift in Sources */,
|
||||
3684AD01C5FCB7616B28F629 /* TimelineMediaPreviewController.swift in Sources */,
|
||||
2A56B00B070F83E0FE571193 /* TimelineMediaPreviewDataSource.swift in Sources */,
|
||||
|
||||
@@ -189,6 +189,7 @@ enum TestablePreviewsDictionary {
|
||||
"TimelineItemMenu_Previews" : TimelineItemMenu_Previews.self,
|
||||
"TimelineItemSendInfoLabel_Previews" : TimelineItemSendInfoLabel_Previews.self,
|
||||
"TimelineItemStyler_Previews" : TimelineItemStyler_Previews.self,
|
||||
"TimelineMediaContentScanningFailureView_Previews" : TimelineMediaContentScanningFailureView_Previews.self,
|
||||
"TimelineMediaPreviewRedactConfirmationView_Previews" : TimelineMediaPreviewRedactConfirmationView_Previews.self,
|
||||
"TimelineReactionView_Previews" : TimelineReactionView_Previews.self,
|
||||
"TimelineReadReceiptsView_Previews" : TimelineReadReceiptsView_Previews.self,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+61
@@ -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.
|
||||
}
|
||||
}
|
||||
+91
-35
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -24,8 +24,8 @@ struct MediaEventsTimelineScreen: View {
|
||||
.environment(\.timelineContext, context.viewState.activeTimelineContext)
|
||||
.timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
|
||||
.sheet(item: $context.mediaPreviewSheetViewModel) { sheet in
|
||||
if case let .media(media) = sheet.state.currentItem {
|
||||
TimelineMediaPreviewDetailsView(item: media,
|
||||
if let mediaItem = sheet.state.currentItem.mediaItem {
|
||||
TimelineMediaPreviewDetailsView(item: mediaItem,
|
||||
context: sheet.context,
|
||||
preferredColorScheme: nil,
|
||||
sheetHeight: $sheetHeight)
|
||||
|
||||
@@ -1355,6 +1355,13 @@ extension PreviewTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func timelineMediaContentScanningFailureView() async throws {
|
||||
for (index, preview) in TimelineMediaContentScanningFailureView_Previews._allPreviews.enumerated() {
|
||||
try await assertSnapshots(matching: preview, step: index)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func timelineMediaPreviewDetailsView() async throws {
|
||||
for (index, preview) in TimelineMediaPreviewDetailsView_Previews._allPreviews.enumerated() {
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bf1b86e4edbfa566a23b703948d4cc9f4b75212ac624497a9d9a0ea079db25ee
|
||||
size 92105
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a45537628abb7586308d35394101a45b03eee87acd22578f64112c009ebcecbd
|
||||
size 99384
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:39074add3578cffa3db6b8afaee1441af54e925bf3f4cc759da6a7cf0fd25d26
|
||||
size 49471
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1fc140f356f21709f4d19d78a324fdf77e89e805e38e4f477cba5e9f228b4aa5
|
||||
size 59340
|
||||
@@ -271,9 +271,6 @@ struct TimelineMediaPreviewDataSourceTests {
|
||||
|
||||
private extension TimelineMediaPreviewDataSource {
|
||||
var currentMediaItemID: TimelineItemIdentifier.EventOrTransactionID? {
|
||||
switch currentItem {
|
||||
case .media(let mediaItem): mediaItem.id
|
||||
case .loading: nil
|
||||
}
|
||||
currentItem.mediaItem?.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,6 +275,64 @@ struct TimelineMediaPreviewViewModelTests {
|
||||
#expect(file.url == mediaItem.fileHandle?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
mutating func safeItem() async throws {
|
||||
// Given a view model with a content scanner that reports the media as safe.
|
||||
setupViewModel(contentScannerService: ContentScannerServiceMock(.init(scanResult: true)))
|
||||
|
||||
// When the preview controller sets the current item.
|
||||
try await loadInitialItem()
|
||||
|
||||
// Then the media should be downloaded as usual.
|
||||
guard case .media = context.viewState.currentItem else {
|
||||
Issue.record("The item should be previewable")
|
||||
return
|
||||
}
|
||||
#expect(mediaProvider.loadFileFromSourceFilenameCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
mutating func unsafeItem() async throws {
|
||||
// Given a view model with a content scanner that reports the media as unsafe.
|
||||
setupViewModel(contentScannerService: ContentScannerServiceMock(.init(scanResult: false)))
|
||||
guard case let .media(mediaItem) = context.viewState.currentItem else {
|
||||
Issue.record("There should be a current item")
|
||||
return
|
||||
}
|
||||
|
||||
// When the preview controller sets the current item.
|
||||
let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: .seconds(1)) { $0.isItemLoaded }
|
||||
context.send(viewAction: .updateCurrentItem(.media(mediaItem)))
|
||||
try await failure.fulfill()
|
||||
|
||||
// Then the media must not be downloaded and the failure should be reflected in the current item.
|
||||
#expect(!mediaProvider.loadFileFromSourceFilenameCalled)
|
||||
#expect(context.viewState.currentItem == .contentScan(.init(media: mediaItem, state: .failure(.notSafe))))
|
||||
#expect(mediaItem.fileHandle == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
mutating func failedScanItem() async throws {
|
||||
// Given a view model with a content scanner that fails to scan the media.
|
||||
let contentScannerService = ContentScannerServiceMock()
|
||||
contentScannerService.loadScanResultFromSourceClosure = { _ in .failure(.failedScanning) }
|
||||
setupViewModel(contentScannerService: contentScannerService)
|
||||
guard case let .media(mediaItem) = context.viewState.currentItem else {
|
||||
Issue.record("There should be a current item")
|
||||
return
|
||||
}
|
||||
|
||||
// When the preview controller sets the current item.
|
||||
let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: .seconds(1)) { $0.isItemLoaded }
|
||||
context.send(viewAction: .updateCurrentItem(.media(mediaItem)))
|
||||
try await failure.fulfill()
|
||||
|
||||
// Then the media must not be downloaded and the failure should be reflected in the current item.
|
||||
#expect(!mediaProvider.loadFileFromSourceFilenameCalled)
|
||||
#expect(context.viewState.currentItem == .contentScan(.init(media: mediaItem, state: .failure(.notFound))))
|
||||
#expect(mediaItem.fileHandle == nil)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func loadInitialItem() async throws {
|
||||
@@ -289,7 +347,9 @@ struct TimelineMediaPreviewViewModelTests {
|
||||
try await deferred.fulfill()
|
||||
}
|
||||
|
||||
private mutating func setupViewModel(initialItemIndex: Int = 0, photoLibraryAuthorizationDenied: Bool = false) {
|
||||
private mutating func setupViewModel(initialItemIndex: Int = 0,
|
||||
photoLibraryAuthorizationDenied: Bool = false,
|
||||
contentScannerService: ContentScannerServiceProtocol? = nil) {
|
||||
let initialItems = makeItems()
|
||||
timelineController = TimelineControllerMock(.init(timelineKind: .media(.mediaFilesScreen), timelineItems: initialItems))
|
||||
|
||||
@@ -298,7 +358,8 @@ struct TimelineMediaPreviewViewModelTests {
|
||||
|
||||
viewModel = TimelineMediaPreviewViewModel(initialItem: initialItems[initialItemIndex],
|
||||
timelineViewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen),
|
||||
timelineController: timelineController),
|
||||
timelineController: timelineController,
|
||||
contentScannerService: contentScannerService),
|
||||
mediaProvider: mediaProvider,
|
||||
photoLibraryManager: photoLibraryManager,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
|
||||
Reference in New Issue
Block a user