diff --git a/apple/InlineIOS/Features/Compose/AttachmentOptionsSheet.swift b/apple/InlineIOS/Features/Compose/AttachmentOptionsSheet.swift new file mode 100644 index 00000000..0c2e48be --- /dev/null +++ b/apple/InlineIOS/Features/Compose/AttachmentOptionsSheet.swift @@ -0,0 +1,47 @@ +import SwiftUI + +enum AttachmentOption { + case library + case camera + case file +} + +struct AttachmentOptionsSheet: View { + let onSelect: (AttachmentOption) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Attach") + .font(.headline) + .foregroundStyle(.secondary) + .padding(.bottom, 4) + + optionButton(title: "Library", icon: "photo.on.rectangle.angled", option: .library) + optionButton(title: "Camera", icon: "camera", option: .camera) + optionButton(title: "File", icon: "folder", option: .file) + } + .padding(16) + .presentationDragIndicator(.visible) + .background(Color(UIColor.systemBackground)) + } + + private func optionButton(title: String, icon: String, option: AttachmentOption) -> some View { + Button { + onSelect(option) + } label: { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + .frame(width: 24) + Text(title) + .font(.system(size: 17, weight: .medium)) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background(Color(UIColor.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + } +} diff --git a/apple/InlineIOS/Features/Compose/ComposeView+UIViews.swift b/apple/InlineIOS/Features/Compose/ComposeView+UIViews.swift index 6c1438bd..3e418945 100644 --- a/apple/InlineIOS/Features/Compose/ComposeView+UIViews.swift +++ b/apple/InlineIOS/Features/Compose/ComposeView+UIViews.swift @@ -90,40 +90,7 @@ extension ComposeView { button.configuration = config button.layer.cornerRadius = 20 button.clipsToBounds = true - - let libraryAction = UIAction( - title: "Photos", - image: UIImage(systemName: "photo.on.rectangle.angled"), - handler: { [weak self] _ in - self?.presentPicker() - } - ) - - let videoAction = UIAction( - title: "Video", - image: UIImage(systemName: "video"), - handler: { [weak self] _ in - self?.presentVideoPicker() - } - ) - - let cameraAction = UIAction( - title: "Camera", - image: UIImage(systemName: "camera"), - handler: { [weak self] _ in - self?.presentCamera() - } - ) - - let fileAction = UIAction( - title: "File", - image: UIImage(systemName: "folder"), - handler: { [weak self] _ in - self?.presentFileManager() - } - ) - button.menu = UIMenu(children: [libraryAction, videoAction, cameraAction, fileAction]) - button.showsMenuAsPrimaryAction = true + button.addTarget(self, action: #selector(attachmentButtonTapped), for: .touchUpInside) return button } diff --git a/apple/InlineIOS/Features/Compose/ComposeView.swift b/apple/InlineIOS/Features/Compose/ComposeView.swift index 47ae4635..eda015d5 100644 --- a/apple/InlineIOS/Features/Compose/ComposeView.swift +++ b/apple/InlineIOS/Features/Compose/ComposeView.swift @@ -44,12 +44,13 @@ class ComposeView: UIView, NSTextLayoutManagerDelegate { var isButtonVisible = false var selectedImage: UIImage? + var pendingVideoURLs: [URL] = [] + var pendingMixedMediaItems: [MixedMediaPreviewItem] = [] var showingPhotoPreview: Bool = false var imageCaption: String = "" enum MediaPickerMode { - case photos - case videos + case library } var attachmentItems: [String: FileMediaItem] = [:] @@ -79,9 +80,11 @@ class ComposeView: UIView, NSTextLayoutManagerDelegate { let previewViewModel = SwiftUIPhotoPreviewViewModel() let multiPhotoPreviewViewModel = SwiftUIPhotoPreviewViewModel() + let videoPreviewViewModel = VideoPreviewViewModel() + let mixedMediaPreviewViewModel = MixedMediaPreviewViewModel() let draftSaveInterval: TimeInterval = 2.0 // Save every 2 seconds var isPickerPresented = false - var activePickerMode: MediaPickerMode = .photos + var activePickerMode: MediaPickerMode = .library // MARK: - UI Components diff --git a/apple/InlineIOS/Features/Compose/DocumentPicker.swift b/apple/InlineIOS/Features/Compose/DocumentPicker.swift index 6281b097..886ad4bb 100644 --- a/apple/InlineIOS/Features/Compose/DocumentPicker.swift +++ b/apple/InlineIOS/Features/Compose/DocumentPicker.swift @@ -27,6 +27,14 @@ extension ComposeView: UIDocumentPickerDelegate { func addFile(_ url: URL) { if isVideoFile(url) { + guard url.startAccessingSecurityScopedResource() else { + Log.shared.error("Failed to access security-scoped resource for video: \(url)") + return + } + defer { + url.stopAccessingSecurityScopedResource() + } + addVideo(url) return } diff --git a/apple/InlineIOS/Features/Compose/PhotoUtils.swift b/apple/InlineIOS/Features/Compose/PhotoUtils.swift index 0de48536..8adcf5f8 100644 --- a/apple/InlineIOS/Features/Compose/PhotoUtils.swift +++ b/apple/InlineIOS/Features/Compose/PhotoUtils.swift @@ -1,3 +1,4 @@ +import AVFoundation import InlineKit import Logger import PhotosUI @@ -6,6 +7,47 @@ import UIKit import UniformTypeIdentifiers extension ComposeView: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + @objc func attachmentButtonTapped() { + presentAttachmentOptionsSheet() + } + + private func presentAttachmentOptionsSheet() { + guard let windowScene = window?.windowScene, + let keyWindow = windowScene.windows.first(where: { $0.isKeyWindow }), + let rootVC = keyWindow.rootViewController + else { return } + + let sheet = UIHostingController( + rootView: AttachmentOptionsSheet { [weak self] option in + guard let self else { return } + rootVC.dismiss(animated: true) { [weak self] in + guard let self else { return } + switch option { + case .library: + presentPicker() + case .camera: + presentCamera() + case .file: + presentFileManager() + } + } + } + ) + sheet.modalPresentationStyle = .pageSheet + + if let presentation = sheet.sheetPresentationController { + if #available(iOS 16.0, *) { + presentation.detents = [.medium()] + } else { + presentation.detents = [.medium()] + } + presentation.prefersGrabberVisible = true + presentation.preferredCornerRadius = 20 + } + + rootVC.present(sheet, animated: true) + } + private func resetComposeStateAfterPreviewSend() { if let attributed = textView.attributedText?.mutableCopy() as? NSMutableAttributedString { let marker = "\u{FFFC}" as NSString @@ -32,10 +74,10 @@ extension ComposeView: UIImagePickerControllerDelegate, UINavigationControllerDe func presentPicker() { guard let windowScene = window?.windowScene, !isPickerPresented else { return } - activePickerMode = .photos + activePickerMode = .library var configuration = PHPickerConfiguration(photoLibrary: .shared()) - configuration.filter = .images + configuration.filter = .any(of: [.images, .videos]) configuration.selectionLimit = 30 let picker = PHPickerViewController(configuration: configuration) @@ -48,21 +90,7 @@ extension ComposeView: UIImagePickerControllerDelegate, UINavigationControllerDe } func presentVideoPicker() { - guard let windowScene = window?.windowScene, !isPickerPresented else { return } - - activePickerMode = .videos - - var configuration = PHPickerConfiguration(photoLibrary: .shared()) - configuration.filter = .videos - configuration.selectionLimit = 10 - - let picker = PHPickerViewController(configuration: configuration) - picker.delegate = self - isPickerPresented = true - - let keyWindow = windowScene.windows.first(where: { $0.isKeyWindow }) - let rootVC = keyWindow?.rootViewController - rootVC?.present(picker, animated: true) + presentPicker() } func presentCamera() { @@ -100,7 +128,12 @@ extension ComposeView: UIImagePickerControllerDelegate, UINavigationControllerDe } func handleDroppedImage(_ image: UIImage) { - guard !previewViewModel.isPresented, !multiPhotoPreviewViewModel.isPresented else { return } + guard + !previewViewModel.isPresented, + !multiPhotoPreviewViewModel.isPresented, + !videoPreviewViewModel.isPresented, + !mixedMediaPreviewViewModel.isPresented + else { return } // For dropped single images, use single photo preview selectedImage = image @@ -143,7 +176,12 @@ extension ComposeView: UIImagePickerControllerDelegate, UINavigationControllerDe func handleMultipleDroppedImages(_ images: [UIImage]) { guard !images.isEmpty else { return } - guard !previewViewModel.isPresented, !multiPhotoPreviewViewModel.isPresented else { return } + guard + !previewViewModel.isPresented, + !multiPhotoPreviewViewModel.isPresented, + !videoPreviewViewModel.isPresented, + !mixedMediaPreviewViewModel.isPresented + else { return } if images.count == 1 { handleDroppedImage(images[0]) @@ -247,6 +285,204 @@ extension ComposeView: UIImagePickerControllerDelegate, UINavigationControllerDe } } + private func presentVideoPreview(with videoURLs: [URL], presenter: UIViewController? = nil) { + guard let firstVideoURL = videoURLs.first else { return } + + cleanupPendingVideoURLs() + pendingVideoURLs = videoURLs + videoPreviewViewModel.caption = "" + videoPreviewViewModel.isPresented = true + + let previewView = SwiftUIVideoPreviewView( + videoURL: firstVideoURL, + totalVideos: videoURLs.count, + caption: Binding( + get: { [weak self] in self?.videoPreviewViewModel.caption ?? "" }, + set: { [weak self] newValue in self?.videoPreviewViewModel.caption = newValue } + ), + isPresented: Binding( + get: { [weak self] in self?.videoPreviewViewModel.isPresented ?? false }, + set: { [weak self] newValue in + self?.videoPreviewViewModel.isPresented = newValue + if !newValue { + self?.dismissVideoPreview() + } + } + ), + onSend: { [weak self] caption in + self?.sendVideos(caption: caption) + } + ) + + let previewVC = UIHostingController(rootView: previewView) + previewVC.modalPresentationStyle = .fullScreen + previewVC.modalTransitionStyle = .crossDissolve + + if let presenter { + presenter.present(previewVC, animated: true) + return + } + + if let windowScene = window?.windowScene, + let keyWindow = windowScene.windows.first(where: { $0.isKeyWindow }), + let rootVC = keyWindow.rootViewController + { + rootVC.present(previewVC, animated: true) + } + } + + func dismissVideoPreview() { + var responder: UIResponder? = self + var currentVC: UIViewController? + + while let nextResponder = responder?.next { + if let viewController = nextResponder as? UIViewController { + currentVC = viewController + break + } + responder = nextResponder + } + + guard let currentVC else { + cleanupPendingVideoURLs() + videoPreviewViewModel.caption = "" + videoPreviewViewModel.isPresented = false + return + } + + var topmostVC = currentVC + while let presentedVC = topmostVC.presentedViewController { + topmostVC = presentedVC + } + + let picker = topmostVC.presentingViewController as? PHPickerViewController + + topmostVC.dismiss(animated: true) { [weak self] in + picker?.dismiss(animated: true) { + self?.isPickerPresented = false + } + + self?.cleanupPendingVideoURLs() + self?.videoPreviewViewModel.caption = "" + self?.videoPreviewViewModel.isPresented = false + } + } + + private func cleanupPendingVideoURLs() { + cleanupTemporaryVideoURLs(in: pendingVideoURLs) + pendingVideoURLs.removeAll() + } + + private func presentMixedMediaPreview( + with items: [MixedMediaPreviewItem], + presenter: UIViewController? = nil + ) { + guard !items.isEmpty else { return } + + cleanupPendingMixedMediaItems() + pendingMixedMediaItems = items + mixedMediaPreviewViewModel.caption = "" + mixedMediaPreviewViewModel.isPresented = true + + let previewView = SwiftUIMixedMediaPreviewView( + items: items, + caption: Binding( + get: { [weak self] in self?.mixedMediaPreviewViewModel.caption ?? "" }, + set: { [weak self] newValue in self?.mixedMediaPreviewViewModel.caption = newValue } + ), + isPresented: Binding( + get: { [weak self] in self?.mixedMediaPreviewViewModel.isPresented ?? false }, + set: { [weak self] newValue in + self?.mixedMediaPreviewViewModel.isPresented = newValue + if !newValue { + self?.dismissMixedMediaPreview() + } + } + ), + onSend: { [weak self] caption in + self?.sendMixedMedia(caption: caption) + } + ) + + let previewVC = UIHostingController(rootView: previewView) + previewVC.modalPresentationStyle = .fullScreen + previewVC.modalTransitionStyle = .crossDissolve + + if let presenter { + presenter.present(previewVC, animated: true) + return + } + + if let windowScene = window?.windowScene, + let keyWindow = windowScene.windows.first(where: { $0.isKeyWindow }), + let rootVC = keyWindow.rootViewController + { + rootVC.present(previewVC, animated: true) + } + } + + private func dismissMixedMediaPreview() { + var responder: UIResponder? = self + var currentVC: UIViewController? + + while let nextResponder = responder?.next { + if let viewController = nextResponder as? UIViewController { + currentVC = viewController + break + } + responder = nextResponder + } + + guard let currentVC else { + cleanupPendingMixedMediaItems() + mixedMediaPreviewViewModel.caption = "" + mixedMediaPreviewViewModel.isPresented = false + return + } + + var topmostVC = currentVC + while let presentedVC = topmostVC.presentedViewController { + topmostVC = presentedVC + } + + let picker = topmostVC.presentingViewController as? PHPickerViewController + + topmostVC.dismiss(animated: true) { [weak self] in + picker?.dismiss(animated: true) { + self?.isPickerPresented = false + } + + self?.cleanupPendingMixedMediaItems() + self?.mixedMediaPreviewViewModel.caption = "" + self?.mixedMediaPreviewViewModel.isPresented = false + } + } + + private func cleanupPendingMixedMediaItems() { + let videoURLs = pendingMixedMediaItems.compactMap(\.videoURL) + cleanupTemporaryVideoURLs(in: videoURLs) + pendingMixedMediaItems.removeAll() + } + + private func cleanupTemporaryVideoURLs(in urls: [URL]) { + for url in urls where url.lastPathComponent.hasPrefix("inline-video-preview-") { + try? FileManager.default.removeItem(at: url) + } + } + + private func copyVideoToTemporaryPreviewURL(from sourceURL: URL) throws -> URL { + let fileExtension = sourceURL.pathExtension.isEmpty ? "mp4" : sourceURL.pathExtension + let temporaryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("inline-video-preview-\(UUID().uuidString)") + .appendingPathExtension(fileExtension) + + if FileManager.default.fileExists(atPath: temporaryURL.path) { + try FileManager.default.removeItem(at: temporaryURL) + } + try FileManager.default.copyItem(at: sourceURL, to: temporaryURL) + return temporaryURL + } + func sendImage(_ image: UIImage, caption: String) { guard let peerId else { return } @@ -333,6 +569,117 @@ extension ComposeView: UIImagePickerControllerDelegate, UINavigationControllerDe // sendMessageHaptic() } + private func sendVideos(caption: String) { + guard let peerId else { return } + let videoURLs = pendingVideoURLs + guard !videoURLs.isEmpty else { return } + + sendButton.configuration?.showsActivityIndicator = true + clearAttachments() + + let trimmedCaption = caption.trimmingCharacters(in: .whitespacesAndNewlines) + let messageCaption = trimmedCaption.isEmpty ? nil : trimmedCaption + let replyToMessageId = ChatState.shared.getState(peer: peerId).replyingMessageId + + Task { [weak self] in + guard let self else { return } + + for (index, url) in videoURLs.enumerated() { + do { + let videoInfo = try await FileCache.saveVideo(url: url) + let mediaItem = FileMediaItem.video(videoInfo) + let isFirst = index == 0 + + await MainActor.run { + Transactions.shared.mutate( + transaction: .sendMessage( + .init( + text: isFirst ? messageCaption : nil, + peerId: peerId, + chatId: chatId ?? 0, + mediaItems: [mediaItem], + replyToMsgId: isFirst ? replyToMessageId : nil, + isSticker: nil, + entities: nil + ) + ) + ) + } + } catch { + Log.shared.error("Failed to save and send video \(index + 1)", error: error) + } + } + + await MainActor.run { [weak self] in + guard let self else { return } + resetComposeStateAfterPreviewSend() + dismissVideoPreview() + sendButton.configuration?.showsActivityIndicator = false + clearAttachments() + ChatState.shared.clearReplyingMessageId(peer: peerId) + } + } + } + + private func sendMixedMedia(caption: String) { + guard let peerId else { return } + let mediaItems = pendingMixedMediaItems + guard !mediaItems.isEmpty else { return } + + sendButton.configuration?.showsActivityIndicator = true + clearAttachments() + + let trimmedCaption = caption.trimmingCharacters(in: .whitespacesAndNewlines) + let messageCaption = trimmedCaption.isEmpty ? nil : trimmedCaption + let replyToMessageId = ChatState.shared.getState(peer: peerId).replyingMessageId + + Task { [weak self] in + guard let self else { return } + + for (index, item) in mediaItems.enumerated() { + do { + let mediaItem: FileMediaItem + switch item { + case let .photo(id: _, image: image): + let photoInfo = try FileCache.savePhoto(image: image, optimize: true) + mediaItem = .photo(photoInfo) + case let .video(id: _, url: url): + let videoInfo = try await FileCache.saveVideo(url: url) + mediaItem = .video(videoInfo) + } + + let isFirst = index == 0 + await MainActor.run { + Transactions.shared.mutate( + transaction: .sendMessage( + .init( + text: isFirst ? messageCaption : nil, + peerId: peerId, + chatId: chatId ?? 0, + mediaItems: [mediaItem], + replyToMsgId: isFirst ? replyToMessageId : nil, + isSticker: nil, + entities: nil + ) + ) + ) + } + } catch { + Log.shared.error("Failed to save and send mixed media item \(index + 1)", error: error) + } + } + + await MainActor.run { [weak self] in + guard let self else { return } + resetComposeStateAfterPreviewSend() + dismissMixedMediaPreview() + sendButton.configuration?.showsActivityIndicator = false + clearAttachments() + ChatState.shared.clearReplyingMessageId(peer: peerId) + } + } + } + func handlePastedImage() { guard let image = UIPasteboard.general.image else { return } @@ -393,28 +740,12 @@ extension ComposeView: UIImagePickerControllerDelegate, UINavigationControllerDe } func addVideo(_ url: URL) { - sendButton.configuration?.showsActivityIndicator = true - - Task { [weak self] in - do { - let videoInfo = try await FileCache.saveVideo(url: url) - let mediaItem = FileMediaItem.video(videoInfo) - let uniqueId = mediaItem.getItemUniqueId() - - await MainActor.run { [weak self] in - guard let self else { return } - attachmentItems[uniqueId] = mediaItem - updateSendButtonVisibility() - sendButton.configuration?.showsActivityIndicator = false - sendMessage() - } - } catch { - Log.shared.error("Failed to save video", error: error) - await MainActor.run { [weak self] in - self?.sendButton.configuration?.showsActivityIndicator = false - self?.showVideoError(error) - } - } + do { + let previewURL = try copyVideoToTemporaryPreviewURL(from: url) + presentVideoPreview(with: [previewURL]) + } catch { + Log.shared.error("Failed to prepare video preview", error: error) + showVideoError(error) } } @@ -445,129 +776,200 @@ extension ComposeView: PHPickerViewControllerDelegate { } return } - + isPickerPresented = false - if activePickerMode == .videos { - handleVideoPickerResults(results, picker: picker) - return - } + Task { [weak self, weak picker] in + guard let self, let picker else { return } + + let loadedItems = await loadUnifiedPickerSelections(results) - // If only one photo selected, use the original single preview - if results.count == 1 { - let result = results.first! - result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self, weak picker] object, error in + await MainActor.run { [weak self, weak picker] in guard let self, let picker else { return } - if let error { - Log.shared.debug("Failed to load image:", file: error.localizedDescription) - DispatchQueue.main.async { - picker.dismiss(animated: true) { [weak self] in - self?.isPickerPresented = false - } + guard !loadedItems.isEmpty else { + picker.dismiss(animated: true) { [weak self] in + self?.isPickerPresented = false } return } - guard let image = object as? UIImage else { - DispatchQueue.main.async { - picker.dismiss(animated: true) { [weak self] in - self?.isPickerPresented = false - } + let photoItems = loadedItems.compactMap(\.image) + let videoItems = loadedItems.compactMap(\.videoURL) + + if videoItems.isEmpty { + if photoItems.count == 1, let image = photoItems.first { + presentSinglePhotoPreview(image, picker: picker) + } else { + presentPhotoBatchPreview(photoItems, picker: picker) } return } - DispatchQueue.main.async { - self.selectedImage = image - self.previewViewModel.isPresented = true - - let previewView = SwiftUIPhotoPreviewView( - image: image, - caption: Binding( - get: { [weak self] in self?.previewViewModel.caption ?? "" }, - set: { [weak self] newValue in self?.previewViewModel.caption = newValue } - ), - isPresented: Binding( - get: { [weak self] in self?.previewViewModel.isPresented ?? false }, - set: { [weak self] newValue in - self?.previewViewModel.isPresented = newValue - if !newValue { - self?.dismissPreview() - } - } - ), - onSend: { [weak self] image, caption in - self?.sendImage(image, caption: caption) - }, - onAddMorePhotos: { [weak self] in - self?.presentPicker() - } - ) - - let previewVC = UIHostingController(rootView: previewView) - previewVC.modalPresentationStyle = .fullScreen - previewVC.modalTransitionStyle = .crossDissolve - - picker.present(previewVC, animated: true) + if photoItems.isEmpty, videoItems.count == 1, let url = videoItems.first { + presentVideoPreview(with: [url], presenter: picker) + return } + + presentMixedMediaPreview( + with: loadedItems.map(\.previewItem), + presenter: picker + ) } - } else { - // Multiple photos selected, use multi-photo preview - loadMultipleImages(from: results, picker: picker) } } - private func handleVideoPickerResults(_ results: [PHPickerResult], picker: PHPickerViewController) { - Task { [weak self, weak picker] in - guard let self, let picker else { return } + private struct LoadedLibraryItem { + let index: Int + let payload: Payload + + enum Payload { + case photo(UIImage) + case video(URL) + } - var loadedItems: [(index: Int, item: FileMediaItem)] = [] + var image: UIImage? { + guard case let .photo(image) = payload else { return nil } + return image + } - await withTaskGroup(of: (Int, FileMediaItem?).self) { group in - for (index, result) in results.enumerated() { - group.addTask { [weak self] in - guard let self else { return (index, nil) } - let item = await self.loadVideoItem(from: result) - return (index, item) - } + var videoURL: URL? { + guard case let .video(url) = payload else { return nil } + return url + } + + var previewItem: MixedMediaPreviewItem { + switch payload { + case let .photo(image): + .photo(id: UUID(), image: image) + case let .video(url): + .video(id: UUID(), url: url) + } + } + } + + private func loadUnifiedPickerSelections(_ results: [PHPickerResult]) async -> [LoadedLibraryItem] { + var loadedItems: [LoadedLibraryItem] = [] + + await withTaskGroup(of: LoadedLibraryItem?.self) { group in + for (index, result) in results.enumerated() { + group.addTask { [weak self] in + guard let self else { return nil } + return await self.loadUnifiedPickerSelection(result, index: index) } + } - for await (index, item) in group { - if let item { - loadedItems.append((index: index, item: item)) - } + for await item in group { + if let item { + loadedItems.append(item) } } + } - await MainActor.run { [weak self, weak picker] in - guard let self, let picker else { return } + return loadedItems.sorted { $0.index < $1.index } + } - let sortedItems = loadedItems.sorted { $0.index < $1.index }.map(\.item) - guard !sortedItems.isEmpty else { - picker.dismiss(animated: true) { [weak self] in - self?.isPickerPresented = false - } + private func loadUnifiedPickerSelection( + _ result: PHPickerResult, + index: Int + ) async -> LoadedLibraryItem? { + if let videoURL = await loadVideoPreviewURL(from: result) { + return LoadedLibraryItem(index: index, payload: .video(videoURL)) + } + + let provider = result.itemProvider + if provider.canLoadObject(ofClass: UIImage.self), + let image = await loadUIImage(from: provider) { + return LoadedLibraryItem(index: index, payload: .photo(image)) + } + + return nil + } + + private func loadUIImage(from provider: NSItemProvider) async -> UIImage? { + await withCheckedContinuation { continuation in + provider.loadObject(ofClass: UIImage.self) { object, error in + if let error { + Log.shared.error("Failed to load image from picker", error: error) + continuation.resume(returning: nil) return } - for item in sortedItems { - let uniqueId = item.getItemUniqueId() - attachmentItems[uniqueId] = item - } + continuation.resume(returning: object as? UIImage) + } + } + } - updateSendButtonVisibility() + private func presentSinglePhotoPreview(_ image: UIImage, picker: PHPickerViewController) { + selectedImage = image + previewViewModel.isPresented = true - picker.dismiss(animated: true) { [weak self] in - guard let self else { return } - isPickerPresented = false - sendMessage() + let previewView = SwiftUIPhotoPreviewView( + image: image, + caption: Binding( + get: { [weak self] in self?.previewViewModel.caption ?? "" }, + set: { [weak self] newValue in self?.previewViewModel.caption = newValue } + ), + isPresented: Binding( + get: { [weak self] in self?.previewViewModel.isPresented ?? false }, + set: { [weak self] newValue in + self?.previewViewModel.isPresented = newValue + if !newValue { + self?.dismissPreview() + } } + ), + onSend: { [weak self] image, caption in + self?.sendImage(image, caption: caption) + }, + onAddMorePhotos: { [weak self] in + self?.presentPicker() + } + ) + + let previewVC = UIHostingController(rootView: previewView) + previewVC.modalPresentationStyle = .fullScreen + previewVC.modalTransitionStyle = .crossDissolve + picker.present(previewVC, animated: true) + } + + private func presentPhotoBatchPreview(_ images: [UIImage], picker: PHPickerViewController) { + guard !images.isEmpty else { + picker.dismiss(animated: true) { [weak self] in + self?.isPickerPresented = false } + return } + + multiPhotoPreviewViewModel.setPhotos(images) + multiPhotoPreviewViewModel.isPresented = true + + let multiPreviewView = SwiftUIPhotoPreviewView( + viewModel: multiPhotoPreviewViewModel, + isPresented: Binding( + get: { [weak self] in self?.multiPhotoPreviewViewModel.isPresented ?? false }, + set: { [weak self] newValue in + self?.multiPhotoPreviewViewModel.isPresented = newValue + if !newValue { + self?.dismissMultiPreview() + } + } + ), + onSend: { [weak self] photoItems in + self?.sendMultipleImages(photoItems) + }, + onAddMorePhotos: { [weak self] in + self?.presentPicker() + } + ) + + let previewVC = UIHostingController(rootView: multiPreviewView) + previewVC.modalPresentationStyle = .fullScreen + previewVC.modalTransitionStyle = .crossDissolve + picker.present(previewVC, animated: true) } - private func loadVideoItem(from result: PHPickerResult) async -> FileMediaItem? { + private func loadVideoPreviewURL(from result: PHPickerResult) async -> URL? { let provider = result.itemProvider let typeIdentifier = [UTType.movie.identifier, UTType.video.identifier] .first(where: { provider.hasItemConformingToTypeIdentifier($0) }) @@ -587,27 +989,12 @@ extension ComposeView: PHPickerViewControllerDelegate { return } - let tempDirectory = FileManager.default.temporaryDirectory - let fileExtension = url.pathExtension.isEmpty ? "mp4" : url.pathExtension - let tempUrl = tempDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension(fileExtension) - do { - try FileManager.default.copyItem(at: url, to: tempUrl) + let temporaryURL = try self.copyVideoToTemporaryPreviewURL(from: url) + continuation.resume(returning: temporaryURL) } catch { Log.shared.error("Failed to copy video file from picker", error: error) continuation.resume(returning: nil) - return - } - - Task { - defer { try? FileManager.default.removeItem(at: tempUrl) } - do { - let videoInfo = try await FileCache.saveVideo(url: tempUrl) - continuation.resume(returning: .video(videoInfo)) - } catch { - Log.shared.error("Failed to save video from picker", error: error) - continuation.resume(returning: nil) - } } } } diff --git a/apple/InlineIOS/Features/Media/NewVideoView.swift b/apple/InlineIOS/Features/Media/NewVideoView.swift index 79f5f9c9..ef0de295 100644 --- a/apple/InlineIOS/Features/Media/NewVideoView.swift +++ b/apple/InlineIOS/Features/Media/NewVideoView.swift @@ -1,6 +1,8 @@ import Combine +import GRDB import InlineKit import InlineUI +import Logger import UIKit final class NewVideoView: UIView { @@ -14,10 +16,13 @@ final class NewVideoView: UIView { private let maskLayer = CAShapeLayer() private var imageConstraints: [NSLayoutConstraint] = [] private var progressCancellable: AnyCancellable? + private var uploadProgressCancellable: AnyCancellable? private var isDownloading = false private var isPresentingViewer = false private var pendingViewerURL: URL? private var downloadProgress: Double = 0 + private var boundUploadVideoLocalId: Int64? + private var uploadProgressEvent: UploadProgressEvent? private var hasText: Bool { fullMessage.message.text?.isEmpty == false @@ -91,7 +96,7 @@ final class NewVideoView: UIView { button.setImage(UIImage(systemName: "xmark", withConfiguration: config), for: .normal) button.tintColor = .white button.isHidden = true - button.accessibilityLabel = "Cancel download" + button.accessibilityLabel = "Cancel transfer" return button }() @@ -144,6 +149,7 @@ final class NewVideoView: UIView { deinit { progressCancellable?.cancel() + uploadProgressCancellable?.cancel() } // MARK: - Setup @@ -267,7 +273,7 @@ final class NewVideoView: UIView { setupMask() setupGestures() updateImage() - updateDurationLabel() + updateTopLeftBadge() updateOverlay() } @@ -275,7 +281,7 @@ final class NewVideoView: UIView { let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) tapGesture.delegate = self addGestureRecognizer(tapGesture) - cancelDownloadButton.addTarget(self, action: #selector(handleCancelDownload), for: .touchUpInside) + cancelDownloadButton.addTarget(self, action: #selector(handleCancelTransfer), for: .touchUpInside) } private func setupMask() { @@ -325,19 +331,23 @@ final class NewVideoView: UIView { == fullMessage.videoInfo?.thumbnail?.bestPhotoSize()?.localPath { updateOverlay() - updateDurationLabel() + updateTopLeftBadge() return } progressCancellable?.cancel() progressCancellable = nil + uploadProgressCancellable?.cancel() + uploadProgressCancellable = nil + boundUploadVideoLocalId = nil + uploadProgressEvent = nil isDownloading = false downloadProgress = 0 downloadProgressView.setProgress(0) setupVideoConstraints() updateImage() - updateDurationLabel() + updateTopLeftBadge() updateOverlay() } @@ -356,11 +366,18 @@ final class NewVideoView: UIView { .map { FileDownloader.shared.isVideoDownloadActive(videoId: $0.id) } ?? false let downloading = !isVideoDownloaded && (isDownloading || globalDownloadActive) + if isUploading, let videoLocalId = fullMessage.videoInfo?.video.id { + bindUploadProgressIfNeeded(videoLocalId: videoLocalId) + } else { + clearUploadProgressBinding() + uploadProgressEvent = nil + } + if isUploading { overlayIconView.isHidden = true overlaySpinner.startAnimating() downloadProgressView.isHidden = true - cancelDownloadButton.isHidden = true + cancelDownloadButton.isHidden = false } else if downloading { overlaySpinner.stopAnimating() overlayIconView.isHidden = true @@ -382,11 +399,41 @@ final class NewVideoView: UIView { } overlayBackground.isHidden = false + updateTopLeftBadge() + } + + private func updateTopLeftBadge() { + if fullMessage.message.status == .sending, + let event = uploadProgressEvent { + durationBadge.isHidden = false + switch event.phase { + case .processing: + durationBadge.text = "Processing..." + case .uploading: + if event.totalBytes > 0, event.bytesSent > 0, event.fraction < 0.999 { + let uploaded = FileHelpers.formatFileSize(UInt64(event.bytesSent)) + let total = FileHelpers.formatFileSize(UInt64(event.totalBytes)) + durationBadge.text = "\(uploaded) / \(total)" + } else { + durationBadge.text = "Uploading..." + } + case .completed: + showDurationBadge() + case .failed: + durationBadge.text = "Upload failed" + case .cancelled: + durationBadge.text = "Upload cancelled" + } + return + } + + showDurationBadge() } - private func updateDurationLabel() { + private func showDurationBadge() { guard let duration = fullMessage.videoInfo?.video.duration, duration > 0 else { durationBadge.isHidden = true + durationBadge.text = nil return } @@ -499,7 +546,22 @@ final class NewVideoView: UIView { } } - @objc private func handleCancelDownload() { + @objc private func handleCancelTransfer() { + let isUploading = fullMessage.message.status == .sending && fullMessage.videoInfo?.video.cdnUrl == nil + if isUploading, + let videoLocalId = fullMessage.videoInfo?.video.id { + cancelUpload( + videoLocalId: videoLocalId, + transactionId: fullMessage.message.transactionId, + randomId: fullMessage.message.randomId + ) + return + } + + cancelDownload() + } + + private func cancelDownload() { guard let videoId = fullMessage.videoInfo?.id else { return } FileDownloader.shared.cancelVideoDownload(videoId: videoId) progressCancellable?.cancel() @@ -510,6 +572,72 @@ final class NewVideoView: UIView { updateOverlay() } + private func cancelUpload(videoLocalId: Int64, transactionId: String?, randomId: Int64?) { + Task { await FileUploader.shared.cancelVideoUpload(videoLocalId: videoLocalId) } + + if let transactionId, !transactionId.isEmpty { + Transactions.shared.cancel(transactionId: transactionId) + } else if let randomId { + Task { + Api.realtime.cancelTransaction(where: { + guard $0.transaction.method == .sendMessage else { return false } + guard case let .sendMessage(input) = $0.transaction.input else { return false } + return input.randomID == randomId + }) + } + } + + Task(priority: .userInitiated) { + let message = fullMessage.message + let chatId = message.chatId + let messageId = message.messageId + let peerId = message.peerId + + do { + try await AppDatabase.shared.dbWriter.write { db in + try Message + .filter(Column("chatId") == chatId) + .filter(Column("messageId") == messageId) + .deleteAll(db) + } + + MessagesPublisher.shared + .messagesDeleted(messageIds: [messageId], peer: peerId) + } catch { + Log.shared.error("Failed to delete local message row for cancel", error: error) + } + } + + clearUploadProgressBinding() + uploadProgressEvent = nil + updateOverlay() + } + + private func bindUploadProgressIfNeeded(videoLocalId: Int64) { + guard boundUploadVideoLocalId != videoLocalId else { return } + + uploadProgressCancellable?.cancel() + boundUploadVideoLocalId = videoLocalId + uploadProgressCancellable = FileUploader + .videoUploadProgressPublisher(videoLocalId: videoLocalId) + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + guard let self else { return } + uploadProgressEvent = event + updateTopLeftBadge() + + if event.phase == .completed || event.phase == .failed || event.phase == .cancelled { + clearUploadProgressBinding() + } + } + } + + private func clearUploadProgressBinding() { + uploadProgressCancellable?.cancel() + uploadProgressCancellable = nil + boundUploadVideoLocalId = nil + } + private func videoLocalUrl() -> URL? { if let localPath = fullMessage.videoInfo?.video.localPath { return FileCache.getUrl(for: .videos, localPath: localPath) diff --git a/apple/InlineIOS/Features/Media/SwiftUIMixedMediaPreviewView.swift b/apple/InlineIOS/Features/Media/SwiftUIMixedMediaPreviewView.swift new file mode 100644 index 00000000..4aaaa646 --- /dev/null +++ b/apple/InlineIOS/Features/Media/SwiftUIMixedMediaPreviewView.swift @@ -0,0 +1,190 @@ +import AVKit +import SwiftUI +import UIKit + +enum MixedMediaPreviewItem: Identifiable { + case photo(id: UUID, image: UIImage) + case video(id: UUID, url: URL) + + var id: UUID { + switch self { + case let .photo(id, _): id + case let .video(id, _): id + } + } + + var videoURL: URL? { + guard case let .video(_, url) = self else { return nil } + return url + } +} + +final class MixedMediaPreviewViewModel: ObservableObject { + @Published var caption: String = "" + @Published var isPresented: Bool = false +} + +struct SwiftUIMixedMediaPreviewView: View { + let items: [MixedMediaPreviewItem] + @Binding var caption: String + @Binding var isPresented: Bool + let onSend: (String) -> Void + + @State private var selectedItemID: UUID? + @FocusState private var isCaptionFocused: Bool + @State private var keyboardHeight: CGFloat = 0 + + var body: some View { + ZStack { + Color(.systemBackground) + .ignoresSafeArea() + + TabView(selection: $selectedItemID) { + ForEach(items) { item in + mediaView(for: item) + .tag(item.id) + } + } + .tabViewStyle(.page(indexDisplayMode: items.count > 1 ? .always : .never)) + .onAppear { + if selectedItemID == nil { + selectedItemID = items.first?.id + } + setupKeyboardObservers() + } + .onDisappear { + removeKeyboardObservers() + } + } + .overlay(alignment: .topLeading) { + Button { + withAnimation(.easeOut(duration: 0.2)) { + isPresented = false + } + } label: { + Circle() + .fill(Color(.secondarySystemBackground)) + .frame(width: 44, height: 44) + .overlay { + Image(systemName: "xmark") + .font(.callout) + .foregroundColor(ThemeManager.shared.textPrimaryColor) + } + } + .padding(.leading, 16) + .padding(.top, 16) + } + .overlay(alignment: .topTrailing) { + Text(items.count == 1 ? "1 item" : "\(items.count) items") + .font(.body) + .foregroundColor(ThemeManager.shared.textPrimaryColor) + .padding(.horizontal, 12) + .frame(height: 44) + .background( + Capsule() + .fill(ThemeManager.shared.surfaceBackgroundColor) + ) + .padding(.trailing, 16) + .padding(.top, 16) + } + .overlay(alignment: .bottom) { + HStack(alignment: .bottom, spacing: 12) { + TextField("Add a caption...", text: $caption, axis: .vertical) + .focused($isCaptionFocused) + .font(.system(size: 16)) + .foregroundColor(ThemeManager.shared.textPrimaryColor) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background { + RoundedRectangle(cornerRadius: 20) + .fill(Color(.systemBackground)) + .stroke(ThemeManager.shared.borderColor, lineWidth: 1) + } + .lineLimit(isCaptionFocused ? (1 ... 4) : (1 ... 1)) + .submitLabel(.done) + .onSubmit { isCaptionFocused = false } + + Button { + if isCaptionFocused { + isCaptionFocused = false + } else { + onSend(caption) + } + } label: { + Image(systemName: isCaptionFocused ? "checkmark" : "arrow.up") + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 44, height: 44) + .background( + Circle() + .fill(ThemeManager.shared.accentColor) + ) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.bottom, keyboardHeight > 0 ? 8 : 20) + } + .statusBarHidden() + } + + @ViewBuilder + private func mediaView(for item: MixedMediaPreviewItem) -> some View { + switch item { + case let .photo(_, image): + Image(uiImage: image) + .resizable() + .scaledToFit() + .ignoresSafeArea() + case let .video(_, url): + MixedMediaVideoItem(url: url) + .ignoresSafeArea() + } + } + + private func setupKeyboardObservers() { + NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillShowNotification, + object: nil, + queue: .main + ) { notification in + if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { + withAnimation(.easeInOut(duration: 0.3)) { + keyboardHeight = keyboardFrame.height + } + } + } + + NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillHideNotification, + object: nil, + queue: .main + ) { _ in + withAnimation(.easeInOut(duration: 0.3)) { + keyboardHeight = 0 + } + } + } + + private func removeKeyboardObservers() { + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) + } +} + +private struct MixedMediaVideoItem: View { + let url: URL + @State private var player: AVPlayer = .init() + + var body: some View { + VideoPlayer(player: player) + .onAppear { + player.replaceCurrentItem(with: AVPlayerItem(url: url)) + player.play() + } + .onDisappear { + player.pause() + player.replaceCurrentItem(with: nil) + } + } +} diff --git a/apple/InlineIOS/Features/Media/SwiftUIVideoPreviewView.swift b/apple/InlineIOS/Features/Media/SwiftUIVideoPreviewView.swift new file mode 100644 index 00000000..4cc752ea --- /dev/null +++ b/apple/InlineIOS/Features/Media/SwiftUIVideoPreviewView.swift @@ -0,0 +1,255 @@ +import AVFoundation +import AVKit +import SwiftUI + +class VideoPreviewViewModel: ObservableObject { + @Published var caption: String = "" + @Published var isPresented: Bool = false +} + +struct SwiftUIVideoPreviewView: View { + let videoURL: URL + let totalVideos: Int + @Binding var caption: String + @Binding var isPresented: Bool + let onSend: (String) -> Void + + @State private var player: AVPlayer = .init() + @State private var keyboardHeight: CGFloat = 0 + @State private var durationText: String? + + @FocusState private var isCaptionFocused: Bool + + private let closeButtonSize: CGFloat = 44 + private let bottomContentPadding: CGFloat = 20 + private let bottomContentSpacing: CGFloat = 12 + private let animationDuration: TimeInterval = 0.3 + + var body: some View { + ZStack { + Color(.systemBackground) + .ignoresSafeArea() + + VideoPlayer(player: player) + .ignoresSafeArea() + } + .overlay(alignment: .topLeading) { + closeButton + .padding(.leading, 16) + .padding(.top, 16) + } + .overlay(alignment: .topTrailing) { + HStack(spacing: 8) { + if totalVideos > 1 { + counterPill + } + + if let durationText { + durationPill(durationText) + } + } + .padding(.trailing, 16) + .padding(.top, 16) + } + .overlay(alignment: .bottom) { + bottomContent + } + .statusBarHidden() + .onAppear { + configurePlayer() + setupKeyboardObservers() + loadDurationText() + } + .onDisappear { + removeKeyboardObservers() + player.pause() + player.replaceCurrentItem(with: nil) + } + } + + private var closeButton: some View { + Button(action: { + withAnimation(.easeOut(duration: 0.2)) { + isPresented = false + } + }) { + if #available(iOS 26.0, *) { + Circle() + .fill(ThemeManager.shared.cardBackgroundColor) + .frame(width: closeButtonSize, height: closeButtonSize) + .overlay { + Image(systemName: "xmark") + .font(.callout) + .foregroundColor(ThemeManager.shared.textPrimaryColor) + } + .glassEffect(.regular, in: Circle()) + } else { + Circle() + .fill(ThemeManager.shared.surfaceBackgroundColor) + .frame(width: closeButtonSize, height: closeButtonSize) + .overlay { + Image(systemName: "xmark") + .font(.callout) + .foregroundColor(ThemeManager.shared.textPrimaryColor) + } + } + } + .buttonStyle(VideoScaleButtonStyle()) + } + + private var counterPill: some View { + Text(totalVideos == 1 ? "1 video" : "\(totalVideos) videos") + .font(.body) + .foregroundColor(ThemeManager.shared.textPrimaryColor) + .padding(.horizontal, 12) + .frame(height: closeButtonSize) + .background { + if #available(iOS 26.0, *) { + Capsule() + .fill(ThemeManager.shared.cardBackgroundColor) + .glassEffect(.regular, in: Capsule()) + } else { + Capsule() + .fill(ThemeManager.shared.surfaceBackgroundColor) + } + } + } + + private func durationPill(_ text: String) -> some View { + Text(text) + .font(.body.monospacedDigit()) + .foregroundColor(ThemeManager.shared.textPrimaryColor) + .padding(.horizontal, 12) + .frame(height: closeButtonSize) + .background { + if #available(iOS 26.0, *) { + Capsule() + .fill(ThemeManager.shared.cardBackgroundColor) + .glassEffect(.regular, in: Capsule()) + } else { + Capsule() + .fill(ThemeManager.shared.surfaceBackgroundColor) + } + } + } + + private var captionTextField: some View { + TextField("Add a caption...", text: $caption, axis: .vertical) + .focused($isCaptionFocused) + .font(.system(size: 16)) + .foregroundColor(ThemeManager.shared.textPrimaryColor) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background { + RoundedRectangle(cornerRadius: 20) + .fill(Color(.systemBackground)) + .stroke(ThemeManager.shared.borderColor, lineWidth: 1) + } + .lineLimit(isCaptionFocused ? (1 ... 4) : (1 ... 1)) + .onSubmit { + isCaptionFocused = false + } + .submitLabel(.done) + .onTapGesture { + if !isCaptionFocused { + isCaptionFocused = true + } + } + } + + private var sendButton: some View { + Button(action: { + if isCaptionFocused { + isCaptionFocused = false + } else { + onSend(caption) + } + }) { + Image(systemName: isCaptionFocused ? "checkmark" : "arrow.up") + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 44, height: 44) + .background( + Circle() + .fill(ThemeManager.shared.accentColor) + ) + } + .buttonStyle(VideoScaleButtonStyle()) + } + + private var bottomContent: some View { + HStack(alignment: .bottom, spacing: bottomContentSpacing) { + captionTextField + sendButton + } + .padding(.horizontal, bottomContentPadding) + .padding(.bottom, keyboardHeight > 0 ? 8 : bottomContentPadding) + } + + private func configurePlayer() { + player.replaceCurrentItem(with: AVPlayerItem(url: videoURL)) + player.play() + } + + private func loadDurationText() { + let asset = AVURLAsset(url: videoURL) + let duration = asset.duration.seconds + + guard duration.isFinite, duration > 0 else { + durationText = nil + return + } + + durationText = Self.formatDuration(duration) + } + + private static func formatDuration(_ duration: Double) -> String { + let totalSeconds = Int(duration.rounded()) + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } + + return String(format: "%d:%02d", minutes, seconds) + } + + private func setupKeyboardObservers() { + NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillShowNotification, + object: nil, + queue: .main + ) { notification in + if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { + withAnimation(.easeInOut(duration: animationDuration)) { + keyboardHeight = keyboardFrame.height + } + } + } + + NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillHideNotification, + object: nil, + queue: .main + ) { _ in + withAnimation(.easeInOut(duration: animationDuration)) { + keyboardHeight = 0 + } + } + } + + private func removeKeyboardObservers() { + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) + } +} + +private struct VideoScaleButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.95 : 1.0) + .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + } +} diff --git a/apple/InlineKit/Sources/InlineKit/ApiClient.swift b/apple/InlineKit/Sources/InlineKit/ApiClient.swift index d2231d8c..5c175d1b 100644 --- a/apple/InlineKit/Sources/InlineKit/ApiClient.swift +++ b/apple/InlineKit/Sources/InlineKit/ApiClient.swift @@ -73,10 +73,26 @@ public final class ApiClient: ObservableObject, @unchecked Sendable { public init() {} private let log = Log.scoped("ApiClient") + + public struct UploadTransferProgress: Sendable { + public let bytesSent: Int64 + public let totalBytes: Int64 + + public var fraction: Double { + guard totalBytes > 0 else { return 0 } + return min(max(Double(bytesSent) / Double(totalBytes), 0), 1) + } + + public init(bytesSent: Int64, totalBytes: Int64) { + self.bytesSent = bytesSent + self.totalBytes = totalBytes + } + } + private final class UploadTaskDelegate: NSObject, URLSessionTaskDelegate { - private let progressHandler: @Sendable (Double) -> Void + private let progressHandler: @Sendable (UploadTransferProgress) -> Void - init(progressHandler: @escaping @Sendable (Double) -> Void) { + init(progressHandler: @escaping @Sendable (UploadTransferProgress) -> Void) { self.progressHandler = progressHandler } @@ -88,8 +104,12 @@ public final class ApiClient: ObservableObject, @unchecked Sendable { totalBytesExpectedToSend: Int64 ) { guard totalBytesExpectedToSend > 0 else { return } - let fraction = Double(totalBytesSent) / Double(totalBytesExpectedToSend) - progressHandler(min(max(fraction, 0), 1)) + progressHandler( + UploadTransferProgress( + bytesSent: totalBytesSent, + totalBytes: totalBytesExpectedToSend + ) + ) } } @@ -666,7 +686,7 @@ public final class ApiClient: ObservableObject, @unchecked Sendable { filename: String, mimeType: MIMEType, videoMetadata: VideoUploadMetadata? = nil, - progress: @escaping @Sendable (Double) -> Void + progress: @escaping @Sendable (UploadTransferProgress) -> Void ) async throws -> UploadFileResult { guard let url = URL(string: "\(baseURL)/uploadFile") else { throw APIError.invalidURL @@ -729,7 +749,8 @@ public final class ApiClient: ObservableObject, @unchecked Sendable { do { let delegate = UploadTaskDelegate(progressHandler: progress) let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) - progress(0) + let estimatedBytes = Int64(multipartFormData.body.count) + progress(UploadTransferProgress(bytesSent: 0, totalBytes: estimatedBytes)) let (data, response) = try await session.upload(for: request, from: multipartFormData.body) session.finishTasksAndInvalidate() @@ -742,7 +763,7 @@ public final class ApiClient: ObservableObject, @unchecked Sendable { let apiResponse = try decoder.decode(APIResponse.self, from: data) switch apiResponse { case let .success(data): - progress(1) + progress(UploadTransferProgress(bytesSent: estimatedBytes, totalBytes: estimatedBytes)) return data case let .error(error, errorCode, description): log.error("Error \(error): \(description ?? "")") diff --git a/apple/InlineKit/Sources/InlineKit/Files/FileCache.swift b/apple/InlineKit/Sources/InlineKit/Files/FileCache.swift index 82626f9b..c15e96e1 100644 --- a/apple/InlineKit/Sources/InlineKit/Files/FileCache.swift +++ b/apple/InlineKit/Sources/InlineKit/Files/FileCache.swift @@ -369,50 +369,19 @@ public actor FileCache: Sendable { guard durationTime.isValid else { throw FileCacheError.failedToSave } let durationSeconds = Int(CMTimeGetSeconds(durationTime).rounded()) - // Persist the video to app cache. - // Keep native MP4 as-is for fast attach and Telegram-like UX. - // Only transcode non-MP4 inputs to guarantee server-accepted format. + // Persist the original selected video to app cache first. + // Preprocessing/transcoding happens in FileUploader right before upload. let directory = FileHelpers.getLocalCacheDirectory(for: .videos) let fileManager = FileManager.default let sourceExtension = url.pathExtension.lowercased() - let needsMp4Transcode = sourceExtension != "mp4" - let shouldPreprocess = needsMp4Transcode - let localPath = UUID().uuidString + ".mp4" + let localExtension = sourceExtension.isEmpty ? "mp4" : sourceExtension + let localPath = UUID().uuidString + "." + localExtension let localUrl = directory.appendingPathComponent(localPath) - - var finalWidth = width - var finalHeight = height - var finalDuration = durationSeconds - var fileSize = 0 - - if shouldPreprocess { - do { - let options = VideoCompressionOptions.uploadDefault(forceTranscode: true) - let result = try await VideoCompressor.shared.compressVideo(at: url, options: options) - defer { - if fileManager.fileExists(atPath: result.url.path) { - try? fileManager.removeItem(at: result.url) - } - } - if fileManager.fileExists(atPath: localUrl.path) { - try fileManager.removeItem(at: localUrl) - } - try fileManager.moveItem(at: result.url, to: localUrl) - finalWidth = result.width - finalHeight = result.height - finalDuration = result.duration - fileSize = Int(result.fileSize) - } catch { - // Non-MP4 inputs must become MP4, so surface preprocessing failure. - throw error - } - } else { - if fileManager.fileExists(atPath: localUrl.path) { - try fileManager.removeItem(at: localUrl) - } - try fileManager.copyItem(at: url, to: localUrl) - fileSize = FileHelpers.getFileSize(at: localUrl) + if fileManager.fileExists(atPath: localUrl.path) { + try fileManager.removeItem(at: localUrl) } + try fileManager.copyItem(at: url, to: localUrl) + let fileSize = FileHelpers.getFileSize(at: localUrl) // Generate or reuse thumbnail let thumbImage: PlatformImage? = if let thumbnail { @@ -426,9 +395,9 @@ public actor FileCache: Sendable { } else { nil } let video = try MediaHelpers.shared.createLocalVideo( - width: finalWidth, - height: finalHeight, - duration: finalDuration, + width: width, + height: height, + duration: durationSeconds, size: fileSize, thumbnail: thumbnailInfo?.photo, localPath: localPath diff --git a/apple/InlineKit/Sources/InlineKit/Files/FileUpload.swift b/apple/InlineKit/Sources/InlineKit/Files/FileUpload.swift index 7b157a1b..a690be41 100644 --- a/apple/InlineKit/Sources/InlineKit/Files/FileUpload.swift +++ b/apple/InlineKit/Sources/InlineKit/Files/FileUpload.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import GRDB import InlineProtocol @@ -44,6 +45,41 @@ public actor FileUploader { private init() {} + // MARK: - Upload ID Helpers + + public static func uploadIdForPhotoLocalId(_ id: Int64) -> String { + "photo_\(id)" + } + + public static func uploadIdForVideoLocalId(_ id: Int64) -> String { + "video_\(id)" + } + + public static func uploadIdForDocumentLocalId(_ id: Int64) -> String { + "document_\(id)" + } + + @MainActor + public static func videoUploadProgressPublisher( + videoLocalId: Int64 + ) -> AnyPublisher { + UploadProgressCenter.shared.publisher(for: uploadIdForVideoLocalId(videoLocalId)) + } + + @MainActor + public static func documentUploadProgressPublisher( + documentLocalId: Int64 + ) -> AnyPublisher { + UploadProgressCenter.shared.publisher(for: uploadIdForDocumentLocalId(documentLocalId)) + } + + @MainActor + public static func photoUploadProgressPublisher( + photoLocalId: Int64 + ) -> AnyPublisher { + UploadProgressCenter.shared.publisher(for: uploadIdForPhotoLocalId(photoLocalId)) + } + // MARK: - Task Management private func registerTask( @@ -73,6 +109,9 @@ public actor FileUploader { uploadTasks.removeValue(forKey: uploadId) cleanupTasks.removeValue(forKey: uploadId) progressHandlers.removeValue(forKey: uploadId) + Task { @MainActor in + UploadProgressCenter.shared.clear(id: uploadId) + } } private func handleTaskFailure(uploadId: String, error: Error) { @@ -80,25 +119,37 @@ public actor FileUploader { "[FileUploader] Upload task failed for \(uploadId)", error: error ) + let phase: UploadPhase = error is CancellationError ? .cancelled : .failed + publishProgressEvent(uploadId: uploadId, phase: phase) uploadTasks.removeValue(forKey: uploadId) cleanupTasks.removeValue(forKey: uploadId) progressHandlers.removeValue(forKey: uploadId) + Task { @MainActor in + UploadProgressCenter.shared.clear(id: uploadId) + } } // MARK: - Progress Tracking - private func updateProgress(uploadId: String, progress: Double) { + private func updateProgress(uploadId: String, progress: ApiClient.UploadTransferProgress) { if var taskInfo = uploadTasks[uploadId] { - taskInfo.progress = progress + taskInfo.progress = progress.fraction uploadTasks[uploadId] = taskInfo // Create a local copy of the handler to avoid actor isolation issues if let handler = progressHandlers[uploadId] { Task { @MainActor in await MainActor.run { - handler(progress) + handler(progress.fraction) } } } + + publishProgressEvent( + uploadId: uploadId, + phase: .uploading, + bytesSent: progress.bytesSent, + totalBytes: progress.totalBytes + ) } } @@ -114,6 +165,23 @@ public actor FileUploader { } } + private func publishProgressEvent( + uploadId: String, + phase: UploadPhase, + bytesSent: Int64 = 0, + totalBytes: Int64 = 0 + ) { + let event = UploadProgressEvent( + id: uploadId, + phase: phase, + bytesSent: bytesSent, + totalBytes: totalBytes + ) + Task { @MainActor in + UploadProgressCenter.shared.publish(event) + } + } + // MARK: - Upload Methods public func uploadPhoto( @@ -134,15 +202,7 @@ public actor FileUploader { let mimeType = format.toMimeType() let uploadId = getUploadId(photoId: photoInfo.photo.id!) - - // Update status to processing - if let handler = progressHandlers[uploadId] { - Task { @MainActor in - await MainActor.run { - handler(-1) // Special value to indicate processing - } - } - } + publishProgressEvent(uploadId: uploadId, phase: .uploading, bytesSent: 0, totalBytes: 0) try startUpload( media: .photo(photoInfo), @@ -171,40 +231,16 @@ public actor FileUploader { } let localUrl = FileHelpers.getLocalCacheDirectory(for: .videos).appendingPathComponent(localPath) + let uploadId = getUploadId(videoId: localVideoId) let fileName = localUrl.lastPathComponent let mimeType = MIMEType(text: FileHelpers.getMimeType(for: localUrl)) - - // Ensure we have required metadata before hitting the API - let (width, height, duration) = try await getValidatedVideoMetadata( - from: resolvedVideoInfo, - localUrl: localUrl - ) - - let uploadId = getUploadId(videoId: localVideoId) - - if let handler = progressHandlers[uploadId] { - Task { @MainActor in - await MainActor.run { - handler(-1) - } - } - } - - let thumbnailPayload = try? thumbnailData(from: resolvedVideoInfo.thumbnail) - let videoMetadata = ApiClient.VideoUploadMetadata( - width: width, - height: height, - duration: duration, - thumbnail: thumbnailPayload?.0, - thumbnailMimeType: thumbnailPayload?.1 - ) + publishProgressEvent(uploadId: uploadId, phase: .processing) try startUpload( media: .video(resolvedVideoInfo), localUrl: localUrl, mimeType: mimeType.text, - fileName: fileName, - videoMetadata: videoMetadata + fileName: fileName ) return localVideoId @@ -222,6 +258,9 @@ public actor FileUploader { ) let fileName = documentInfo.document.fileName ?? "document" let mimeType = documentInfo.document.mimeType ?? "application/octet-stream" + if let localId = documentInfo.document.id { + publishProgressEvent(uploadId: getUploadId(documentId: localId), phase: .uploading) + } try startUpload( media: .document(documentInfo), localUrl: localUrl, @@ -238,8 +277,7 @@ public actor FileUploader { localUrl: URL, mimeType: String, fileName: String, - priority: TaskPriority = .userInitiated, - videoMetadata: ApiClient.VideoUploadMetadata? = nil + priority: TaskPriority = .userInitiated ) throws { let type: MessageFileType let uploadId: String @@ -272,7 +310,6 @@ public actor FileUploader { return } - let metadata = videoMetadata let task = Task(priority: priority) { try await FileUploader.shared.performUpload( uploadId: uploadId, @@ -280,8 +317,7 @@ public actor FileUploader { localUrl: localUrl, mimeType: mimeType, fileName: fileName, - type: type, - videoMetadata: metadata + type: type ) } @@ -295,26 +331,39 @@ public actor FileUploader { localUrl: URL, mimeType: String, fileName: String, - type: MessageFileType, - videoMetadata: ApiClient.VideoUploadMetadata? + type: MessageFileType ) async throws -> UploadResult { Log.shared.debug("[FileUploader] Starting upload for \(uploadId)") - // Compress image if it's a photo - let uploadUrl: URL + var uploadUrl = localUrl + var effectiveMimeType = mimeType + var effectiveFileName = fileName + var effectiveVideoMetadata: ApiClient.VideoUploadMetadata? + var cleanupURL: URL? + if case .photo = media { do { let options = mimeType.lowercased().contains("png") ? ImageCompressionOptions.defaultPNG : ImageCompressionOptions.defaultPhoto uploadUrl = try await ImageCompressor.shared.compressImage(at: localUrl, options: options) - } catch { - // Fallback to original URL if compression fails uploadUrl = localUrl } - } else { - uploadUrl = localUrl + } else if case let .video(videoInfo) = media { + publishProgressEvent(uploadId: uploadId, phase: .processing) + let preparedVideo = try await prepareVideoUpload(localUrl: localUrl, videoInfo: videoInfo) + uploadUrl = preparedVideo.url + effectiveMimeType = preparedVideo.mimeType + effectiveFileName = preparedVideo.fileName + effectiveVideoMetadata = preparedVideo.metadata + cleanupURL = preparedVideo.cleanupURL + } + + defer { + if let cleanupURL, FileManager.default.fileExists(atPath: cleanupURL.path) { + try? FileManager.default.removeItem(at: cleanupURL) + } } // get data from file @@ -326,12 +375,19 @@ public actor FileUploader { let result = try await ApiClient.shared.uploadFile( type: type, data: data, - filename: fileName, - mimeType: MIMEType(text: mimeType), - videoMetadata: videoMetadata, + filename: effectiveFileName, + mimeType: MIMEType(text: effectiveMimeType), + videoMetadata: effectiveVideoMetadata, progress: progressHandler ) + publishProgressEvent( + uploadId: uploadId, + phase: .completed, + bytesSent: Int64(data.count), + totalBytes: Int64(data.count) + ) + // TODO: Set compressed file in db if it was created // return IDs @@ -370,9 +426,13 @@ public actor FileUploader { if let taskInfo = uploadTasks[uploadId] { taskInfo.task.cancel() + publishProgressEvent(uploadId: uploadId, phase: .cancelled) uploadTasks.removeValue(forKey: uploadId) cleanupTasks.removeValue(forKey: uploadId) progressHandlers.removeValue(forKey: uploadId) + Task { @MainActor in + UploadProgressCenter.shared.clear(id: uploadId) + } } } @@ -383,8 +443,12 @@ public actor FileUploader { public func cancelAll() { Log.shared.debug("[FileUploader] Cancelling all uploads") - for (_, taskInfo) in uploadTasks { + for (uploadId, taskInfo) in uploadTasks { taskInfo.task.cancel() + publishProgressEvent(uploadId: uploadId, phase: .cancelled) + Task { @MainActor in + UploadProgressCenter.shared.clear(id: uploadId) + } } uploadTasks.removeAll() @@ -442,15 +506,15 @@ public actor FileUploader { // MARK: - Helpers private func getUploadId(photoId: Int64) -> String { - "photo_\(photoId)" + Self.uploadIdForPhotoLocalId(photoId) } private func getUploadId(videoId: Int64) -> String { - "video_\(videoId)" + Self.uploadIdForVideoLocalId(videoId) } private func getUploadId(documentId: Int64) -> String { - "document_\(documentId)" + Self.uploadIdForDocumentLocalId(documentId) } private func resolveLocalVideoId(for video: Video) throws -> Int64 { @@ -471,7 +535,9 @@ public actor FileUploader { } // Nonisolated helper so progress closures don't capture actor-isolated state - nonisolated static func progressHandler(for uploadId: String) -> @Sendable (Double) -> Void { + static func progressHandler( + for uploadId: String + ) -> @Sendable (ApiClient.UploadTransferProgress) -> Void { return { progress in Task { await FileUploader.shared.updateProgress(uploadId: uploadId, progress: progress) @@ -491,6 +557,88 @@ public actor FileUploader { return (data, mimeType) } + private struct PreparedVideoUpload { + let url: URL + let fileName: String + let mimeType: String + let metadata: ApiClient.VideoUploadMetadata + let cleanupURL: URL? + } + + private func prepareVideoUpload( + localUrl: URL, + videoInfo: VideoInfo + ) async throws -> PreparedVideoUpload { + let sourceExtension = localUrl.pathExtension.lowercased() + let needsMp4Transcode = sourceExtension != "mp4" + let options = VideoCompressionOptions.uploadDefault(forceTranscode: needsMp4Transcode) + let thumbnailPayload = try? thumbnailData(from: videoInfo.thumbnail) + let thumbnailData_ = thumbnailPayload?.0 + let thumbnailMimeType = thumbnailPayload?.1 + + func makeMetadata(width: Int, height: Int, duration: Int) -> ApiClient.VideoUploadMetadata { + ApiClient.VideoUploadMetadata( + width: width, + height: height, + duration: duration, + thumbnail: thumbnailData_, + thumbnailMimeType: thumbnailMimeType + ) + } + + func sourceMetadata() async throws -> ApiClient.VideoUploadMetadata { + let (width, height, duration) = try await self.getValidatedVideoMetadata(from: videoInfo, localUrl: localUrl) + return makeMetadata(width: width, height: height, duration: duration) + } + + if needsMp4Transcode { + let result = try await VideoCompressor.shared.compressVideo(at: localUrl, options: options) + return PreparedVideoUpload( + url: result.url, + fileName: result.url.lastPathComponent, + mimeType: "video/mp4", + metadata: makeMetadata(width: result.width, height: result.height, duration: result.duration), + cleanupURL: result.url + ) + } + + do { + let result = try await VideoCompressor.shared.compressVideo(at: localUrl, options: options) + return PreparedVideoUpload( + url: result.url, + fileName: result.url.lastPathComponent, + mimeType: "video/mp4", + metadata: makeMetadata(width: result.width, height: result.height, duration: result.duration), + cleanupURL: result.url + ) + } catch VideoCompressionError.compressionNotNeeded { + return PreparedVideoUpload( + url: localUrl, + fileName: localUrl.lastPathComponent, + mimeType: "video/mp4", + metadata: try await sourceMetadata(), + cleanupURL: nil + ) + } catch VideoCompressionError.compressionNotEffective { + return PreparedVideoUpload( + url: localUrl, + fileName: localUrl.lastPathComponent, + mimeType: "video/mp4", + metadata: try await sourceMetadata(), + cleanupURL: nil + ) + } catch { + Log.shared.warning("[FileUploader] Video compression failed, falling back to original MP4 for \(localUrl.lastPathComponent)") + return PreparedVideoUpload( + url: localUrl, + fileName: localUrl.lastPathComponent, + mimeType: "video/mp4", + metadata: try await sourceMetadata(), + cleanupURL: nil + ) + } + } + // MARK: - Video Metadata Helpers private func getValidatedVideoMetadata( diff --git a/apple/InlineKit/Sources/InlineKit/Files/UploadProgress.swift b/apple/InlineKit/Sources/InlineKit/Files/UploadProgress.swift new file mode 100644 index 00000000..87c8ca7f --- /dev/null +++ b/apple/InlineKit/Sources/InlineKit/Files/UploadProgress.swift @@ -0,0 +1,68 @@ +import Combine +import Foundation + +public enum UploadPhase: String, Sendable, Equatable { + case processing + case uploading + case completed + case failed + case cancelled +} + +public struct UploadProgressEvent: Sendable, Equatable { + public let id: String + public let phase: UploadPhase + public let bytesSent: Int64 + public let totalBytes: Int64 + + public var fraction: Double { + guard totalBytes > 0 else { return 0 } + return min(max(Double(bytesSent) / Double(totalBytes), 0), 1) + } + + public init( + id: String, + phase: UploadPhase, + bytesSent: Int64 = 0, + totalBytes: Int64 = 0 + ) { + self.id = id + self.phase = phase + self.bytesSent = bytesSent + self.totalBytes = totalBytes + } +} + +@MainActor +public final class UploadProgressCenter { + public static let shared = UploadProgressCenter() + + private var publishers: [String: CurrentValueSubject] = [:] + + private init() {} + + public func publisher(for id: String) -> AnyPublisher { + if let publisher = publishers[id] { + return publisher.eraseToAnyPublisher() + } + + let initial = UploadProgressEvent(id: id, phase: .uploading, bytesSent: 0, totalBytes: 0) + let publisher = CurrentValueSubject(initial) + publishers[id] = publisher + return publisher.eraseToAnyPublisher() + } + + public func publish(_ event: UploadProgressEvent) { + if let publisher = publishers[event.id] { + publisher.send(event) + return + } + + let publisher = CurrentValueSubject(event) + publishers[event.id] = publisher + } + + public func clear(id: String) { + publishers[id] = nil + } +} diff --git a/apple/InlineKit/Tests/InlineKitTests/UploadProgressEventTests.swift b/apple/InlineKit/Tests/InlineKitTests/UploadProgressEventTests.swift new file mode 100644 index 00000000..a3890551 --- /dev/null +++ b/apple/InlineKit/Tests/InlineKitTests/UploadProgressEventTests.swift @@ -0,0 +1,65 @@ +import Combine +import Foundation +import Testing +@testable import InlineKit + +@Suite("Upload Progress Event") +struct UploadProgressEventTests { + @Test("fraction is zero when total bytes is zero") + func zeroTotalFraction() { + let event = UploadProgressEvent( + id: "video_1", + phase: .uploading, + bytesSent: 500, + totalBytes: 0 + ) + + #expect(event.fraction == 0) + } + + @Test("fraction is clamped between 0 and 1") + func fractionClamping() { + let negative = UploadProgressEvent( + id: "video_1", + phase: .uploading, + bytesSent: -10, + totalBytes: 100 + ) + #expect(negative.fraction == 0) + + let over = UploadProgressEvent( + id: "video_1", + phase: .uploading, + bytesSent: 200, + totalBytes: 100 + ) + #expect(over.fraction == 1) + } + + @MainActor + @Test("progress center publishes latest event") + func progressCenterPublishesLatestEvent() { + let id = "video_\(UUID().uuidString)" + var seen: [UploadProgressEvent] = [] + + let cancellable = UploadProgressCenter.shared + .publisher(for: id) + .sink { event in + seen.append(event) + } + defer { + cancellable.cancel() + UploadProgressCenter.shared.clear(id: id) + } + + let next = UploadProgressEvent( + id: id, + phase: .processing, + bytesSent: 0, + totalBytes: 100 + ) + UploadProgressCenter.shared.publish(next) + + #expect(seen.contains(next)) + } +} diff --git a/apple/InlineMac/Views/Message/Media/NewVideoView.swift b/apple/InlineMac/Views/Message/Media/NewVideoView.swift index a3a30689..f7cca893 100644 --- a/apple/InlineMac/Views/Message/Media/NewVideoView.swift +++ b/apple/InlineMac/Views/Message/Media/NewVideoView.swift @@ -279,6 +279,9 @@ final class NewVideoView: NSView { private var isDownloading = false private var isShowingPreview = false private var progressCancellable: AnyCancellable? + private var uploadProgressCancellable: AnyCancellable? + private var boundUploadVideoLocalId: Int64? + private var uploadProgressEvent: UploadProgressEvent? private var suppressNextClick = false private enum ActiveTransfer { case uploading(videoLocalId: Int64, transactionId: String?, randomId: Int64?) @@ -340,12 +343,13 @@ final class NewVideoView: NSView { // Even if the thumbnail is unchanged, refresh overlay to reflect download/upload state changes. refreshDownloadFlags() updateOverlay() + updateTopLeftBadge() return } refreshDownloadFlags() updateImage() - updateDurationLabel() + updateTopLeftBadge() updateOverlay() } @@ -395,7 +399,7 @@ final class NewVideoView: NSView { updateImage() setupMasks() setupClickGesture() - updateDurationLabel() + updateTopLeftBadge() updateOverlay() } @@ -647,10 +651,41 @@ final class NewVideoView: NSView { CATransaction.commit() } - private func updateDurationLabel() { + private func updateTopLeftBadge() { + if fullMessage.message.status == .sending, + let event = uploadProgressEvent { + durationBadge.isHidden = false + durationBadgeBackground.isHidden = false + + switch event.phase { + case .processing: + durationBadge.stringValue = "Processing..." + case .uploading: + if event.totalBytes > 0, event.bytesSent > 0, event.fraction < 0.999 { + let uploaded = FileHelpers.formatFileSize(UInt64(event.bytesSent)) + let total = FileHelpers.formatFileSize(UInt64(event.totalBytes)) + durationBadge.stringValue = "\(uploaded) / \(total)" + } else { + durationBadge.stringValue = "Uploading..." + } + case .completed: + showDurationBadge() + case .failed: + durationBadge.stringValue = "Upload failed" + case .cancelled: + durationBadge.stringValue = "Upload cancelled" + } + return + } + + showDurationBadge() + } + + private func showDurationBadge() { guard let durationSeconds = fullMessage.videoInfo?.video.duration, durationSeconds > 0 else { durationBadge.isHidden = true durationBadgeBackground.isHidden = true + durationBadge.stringValue = "" return } @@ -694,6 +729,12 @@ final class NewVideoView: NSView { } let isUploading = fullMessage.message.status == .sending && fullMessage.videoInfo?.video.cdnUrl == nil + if isUploading, let videoLocalId = fullMessage.videoInfo?.video.id { + bindUploadProgressIfNeeded(videoLocalId: videoLocalId) + } else { + clearUploadProgressBinding() + uploadProgressEvent = nil + } let globalDownloadActive = fullMessage.videoInfo .map { FileDownloader.shared.isVideoDownloadActive(videoId: $0.id) } ?? false @@ -724,6 +765,8 @@ final class NewVideoView: NSView { } else { activeTransfer = nil } + + updateTopLeftBadge() } private func refreshDownloadFlags() { @@ -733,6 +776,31 @@ final class NewVideoView: NSView { } } + private func bindUploadProgressIfNeeded(videoLocalId: Int64) { + guard boundUploadVideoLocalId != videoLocalId else { return } + + uploadProgressCancellable?.cancel() + boundUploadVideoLocalId = videoLocalId + uploadProgressCancellable = FileUploader + .videoUploadProgressPublisher(videoLocalId: videoLocalId) + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + guard let self else { return } + uploadProgressEvent = event + updateTopLeftBadge() + + if event.phase == .completed || event.phase == .failed || event.phase == .cancelled { + clearUploadProgressBinding() + } + } + } + + private func clearUploadProgressBinding() { + uploadProgressCancellable?.cancel() + uploadProgressCancellable = nil + boundUploadVideoLocalId = nil + } + // MARK: - Click / Preview private func setupClickGesture() { @@ -824,6 +892,7 @@ final class NewVideoView: NSView { deinit { progressCancellable?.cancel() + uploadProgressCancellable?.cancel() } private func cancelActiveTransfer() { @@ -878,6 +947,8 @@ final class NewVideoView: NSView { isDownloading = false progressCancellable?.cancel() progressCancellable = nil + clearUploadProgressBinding() + uploadProgressEvent = nil updateOverlay() } } diff --git a/apple/InlineShareExtension/ShareState.swift b/apple/InlineShareExtension/ShareState.swift index 8bec78f9..956baa92 100644 --- a/apple/InlineShareExtension/ShareState.swift +++ b/apple/InlineShareExtension/ShareState.swift @@ -1243,8 +1243,8 @@ class ShareState: ObservableObject { let uploadResult: InlineKit.UploadFileResult let itemIndex = processedItems - let progressHandler: (Double) -> Void = { [weak self] progress in - let itemProgress = (Double(itemIndex) + progress) / Double(totalItems) + let progressHandler: @Sendable (ApiClient.UploadTransferProgress) -> Void = { [weak self] progress in + let itemProgress = (Double(itemIndex) + progress.fraction) / Double(totalItems) Task { @MainActor in self?.uploadProgress = itemProgress * 0.9 } @@ -1301,9 +1301,21 @@ class ShareState: ObservableObject { ] ) } + let videoData = try Data(contentsOf: prepared.url, options: .mappedIfSafe) + guard Int64(videoData.count) <= Self.maxVideoFileSizeBytes else { + throw NSError( + domain: "ShareError", + code: 3, + userInfo: [ + NSLocalizedDescriptionKey: + "\(prepared.fileName) is too large. Maximum size is \(Self.maxFileSizeDisplay(for: Self.maxVideoFileSizeBytes))." + ] + ) + } let videoMetadata = try await buildVideoMetadata(from: prepared.url) - uploadResult = try await apiClient.uploadVideoMultipart( - fileURL: prepared.url, + uploadResult = try await apiClient.uploadFile( + type: .video, + data: videoData, filename: prepared.fileName, mimeType: prepared.mimeType, videoMetadata: videoMetadata,