diff --git a/Scripts/verify.sh b/Scripts/verify.sh index 910f577..3ec377a 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -336,6 +336,14 @@ grep -q 'let items = pickedMediaItems' Sources/Shared/UI/ChatView.swift || { echo "Gallery staging must run on picker dismissal only" exit 1 } +grep -q 'onDismissRequest' Sources/Shared/UI/SystemPhotoCameraView.swift || { + echo "The camera picker must request dismissal before background preparation" + exit 1 +} +grep -q 'onDismissRequest' Sources/Shared/UI/ChatView.swift || { + echo "The camera cover must dismiss through the SwiftUI binding" + exit 1 +} grep -q 'ArchiveSyncCursorPolicy.requestPosition' Sources/Shared/XMPP/XMPPService.swift || { echo "MAM reconnects must prefer the durable archive UID over timestamps" exit 1 diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift index d974d3f..c68d080 100644 --- a/Sources/Shared/UI/ChatView.swift +++ b/Sources/Shared/UI/ChatView.swift @@ -254,13 +254,23 @@ struct ChatView: View { } ) { SystemPhotoCameraView( - onMedia: { result in + onDismissRequest: { + // Runs synchronously in the picker delegate while the + // camera is still presented: dismiss through the + // binding so SwiftUI owns the transition, and reserve + // the archive-sync suspension across the dismissal + // while the file is prepared in the background. showingPhotoCamera = false + photoCameraIsPreparingResult = true + }, + onMedia: { result in switch result { case .success(let media): photoCameraIsPreparingResult = true Task { await stageCapturedMedia(media) } case .failure(let error): + photoCameraIsPreparingResult = false + releaseArchiveSyncAfterCapture() model.errorMessage = error.localizedDescription } }, @@ -1227,11 +1237,12 @@ struct ChatView: View { } } + @discardableResult private func stageImportedFiles( _ urls: [URL], preferredKind: ChatMessage.Kind? = nil - ) async { - guard !urls.isEmpty else { return } + ) async -> Bool { + guard !urls.isEmpty else { return false } isPreparingAttachments = true defer { isPreparingAttachments = false } let prepared = await model.prepareAttachmentDrafts( @@ -1239,10 +1250,11 @@ struct ChatView: View { preferredKind: preferredKind, for: conversation.jid ) - guard !prepared.isEmpty else { return } + guard !prepared.isEmpty else { return false } model.discardAttachmentDrafts(attachmentDrafts) attachmentDrafts = prepared presentAttachmentPreviewAfterPickerDismissal() + return true } private func stageCapturedMedia(_ media: CapturedCameraMedia) async { @@ -1251,7 +1263,10 @@ struct ChatView: View { photoCameraIsPreparingResult = false releaseArchiveSyncAfterCapture() } - await stageImportedFiles([media.url], preferredKind: media.kind) + let staged = await stageImportedFiles([media.url], preferredKind: media.kind) + if !staged, model.errorMessage == nil { + model.errorMessage = "Камера вернула файл, который не удалось подготовить к отправке." + } } private func presentCamera() { diff --git a/Sources/Shared/UI/SystemPhotoCameraView.swift b/Sources/Shared/UI/SystemPhotoCameraView.swift index d67fd94..f20f3eb 100644 --- a/Sources/Shared/UI/SystemPhotoCameraView.swift +++ b/Sources/Shared/UI/SystemPhotoCameraView.swift @@ -12,6 +12,13 @@ import UIKit @MainActor struct SystemPhotoCameraView: UIViewControllerRepresentable { + /// Called synchronously on the main thread inside + /// `didFinishPickingMediaWithInfo`, before any background preparation. + /// The host must flip its `isPresented` binding here so SwiftUI drives + /// the dismissal; letting UIImagePickerController dismiss itself leaves + /// the fullScreenCover state desynchronized and breaks the follow-up + /// preview presentation. + let onDismissRequest: () -> Void let onMedia: (Result) -> Void let onCancel: () -> Void @@ -20,7 +27,11 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator(onMedia: onMedia, onCancel: onCancel) + Coordinator( + onDismissRequest: onDismissRequest, + onMedia: onMedia, + onCancel: onCancel + ) } func makeUIViewController(context: Context) -> UIImagePickerController { @@ -42,14 +53,17 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable { ) {} final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + private let onDismissRequest: () -> Void private let onMedia: (Result) -> Void private let onCancel: () -> Void private var completed = false init( + onDismissRequest: @escaping () -> Void, onMedia: @escaping (Result) -> Void, onCancel: @escaping () -> Void ) { + self.onDismissRequest = onDismissRequest self.onMedia = onMedia self.onCancel = onCancel } @@ -60,6 +74,7 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable { ) { guard !completed else { return } completed = true + onDismissRequest() let mediaType = (info[.mediaType] as? String).flatMap { UTType($0) } if mediaType?.conforms(to: .movie) == true,