From 1e3e928418c2cd408bb4811dcca446d85b35d851 Mon Sep 17 00:00:00 2001 From: Dena Sohrabi <87666169+dena-sohrabi@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:03:46 +0330 Subject: [PATCH 1/6] Improve video upload pipeline with typed progress and preprocessing --- ...26-02-22-awesome-video-send-non-ui-plan.md | 81 ++++ .../Features/Media/NewVideoView.swift | 106 ++++- .../Sources/InlineKit/ApiClient.swift | 37 +- .../Sources/InlineKit/Files/FileUpload.swift | 449 +++++++++++++++--- .../InlineKit/Files/VideoCompression.swift | 32 +- .../FileUploadProgressTests.swift | 72 +++ .../Views/Message/Media/NewVideoView.swift | 112 ++++- 7 files changed, 772 insertions(+), 117 deletions(-) create mode 100644 .agent-docs/2026-02-22-awesome-video-send-non-ui-plan.md create mode 100644 apple/InlineKit/Tests/InlineKitTests/FileUploadProgressTests.swift diff --git a/.agent-docs/2026-02-22-awesome-video-send-non-ui-plan.md b/.agent-docs/2026-02-22-awesome-video-send-non-ui-plan.md new file mode 100644 index 00000000..52531be2 --- /dev/null +++ b/.agent-docs/2026-02-22-awesome-video-send-non-ui-plan.md @@ -0,0 +1,81 @@ +# Awesome Video Sending (Thread 1061) - Non-UI Plan + +## Scope and Goal +- Scope: shared video send/upload pipeline only (InlineKit + API interactions), no picker redesign and no "Library merge" UX work. +- Goal: reach thread targets for reliable long video send, pre-upload processing/compression, true transfer progress data, and processing/upload byte state availability for message surfaces. + +## Source Inputs Reviewed +- Thread data: chat `1061` (`spec: awesome video sending`) via Inline CLI. +- Telegram references (local): + - `/Users/dena/dev/Telegram-iOS/submodules/LegacyComponents/Sources/TGMediaVideoConverter.m` + - `/Users/dena/dev/Telegram-iOS/submodules/TelegramCore/Sources/State/PendingMessageManager.swift` + - `/Users/dena/dev/Telegram-iOS/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift` +- Inline architecture: + - `apple/InlineKit/Sources/InlineKit/Files/FileCache.swift` + - `apple/InlineKit/Sources/InlineKit/Files/VideoCompression.swift` + - `apple/InlineKit/Sources/InlineKit/Files/FileUpload.swift` + - `apple/InlineKit/Sources/InlineKit/ApiClient.swift` + - `server/src/methods/uploadFile.ts` +- Best-practice references: + - Apple AVFoundation export/session state + progress + network optimization docs. + - Apple URLSessionTaskDelegate upload progress callback docs (`didSendBodyData`). + +## Facts From Thread 1061 (Must-Haves) +- Pre-upload video processing/compression for faster uploads. +- Real upload progress (not fake spinner-only state). +- Report uploaded bytes and total bytes in upload state. +- Communicate processing state during resize/compression. +- Keep stage 3 out of scope for this PR: + - multipart upload + - resumable upload after app restart + - major server/client large-file infrastructure rework +- Pipeline is shared with macOS; solution must be shared-safe. +- Add tests and ensure they pass. + +## Current Gaps +- MP4s are currently attached as-is in `FileCache.saveVideo`, so most camera videos skip compression. +- Upload progress is a `Double` fraction with legacy sentinel `-1` for processing. +- No first-class bytes-sent / bytes-total model for upload. +- Video upload state is not exposed as structured progress stream for consumers. + +## Non-UI Implementation Plan +1. Add typed upload progress model in `InlineKit`. + - Introduce a single source-of-truth upload progress snapshot with stages: `processing`, `uploading`, `completed`, `failed`. + - Include `bytesSent`, `totalBytes`, and clamped fraction. + +2. Upgrade API upload delegate progress payload. + - Replace raw fraction callback with structured transport progress from `ApiClient.uploadFile`. + - Keep behavior compatible and deterministic (`0` at start, `1` on success). + +3. Move video preprocessing into upload pipeline. + - Keep `FileCache.saveVideo` for secure copy/transcode normalization and local preview persistence. + - In `FileUploader.performUpload`, preprocess video for upload before network transfer: + - attempt compression for large/high-bitrate videos + - fall back safely to original local file when compression is unnecessary/ineffective + - propagate processing state before upload starts + - use actual upload payload size for byte totals exposed to progress state + +4. Improve cancellation behavior during video export. + - Ensure compression/export is cancellation-aware, so canceling send aborts preprocessing promptly. + +5. Expose upload progress stream APIs from `FileUploader`. + - Add publisher API per media id (initially video path needed for this task). + - Maintain existing lifecycle semantics for completion/failure and cleanup. + +6. Add tests. + - Unit tests for upload progress stage/fraction/byte mapping behavior. + - Keep existing `VideoCompressionTests` passing. + +## Risk Controls +- Do not modify server contract; send same multipart fields (`type`, `file`, video metadata + thumbnail). +- Do not implement multipart/resume in this PR. +- Keep shared pipeline behavior valid for both iOS and macOS. + +## Validation Plan +- `cd apple/InlineKit && swift test --filter VideoCompressionTests` +- `cd apple/InlineKit && swift test --filter FileUploadProgressTests` +- If filters are unavailable in this environment, run full `swift test` for InlineKit. + +## Review Checkpoint (Deep Understanding) +- This plan directly maps to the highest-priority thread asks (compression, processing, real upload progress with bytes) and intentionally excludes stage-3 architecture work. +- The approach is incremental: no API break on server side, minimal behavior change surface outside upload pipeline, and test-backed progress semantics. diff --git a/apple/InlineIOS/Features/Media/NewVideoView.swift b/apple/InlineIOS/Features/Media/NewVideoView.swift index 79f5f9c9..0f8593e0 100644 --- a/apple/InlineIOS/Features/Media/NewVideoView.swift +++ b/apple/InlineIOS/Features/Media/NewVideoView.swift @@ -13,7 +13,11 @@ final class NewVideoView: UIView { private let cornerRadius: CGFloat = 16.0 private let maskLayer = CAShapeLayer() private var imageConstraints: [NSLayoutConstraint] = [] - private var progressCancellable: AnyCancellable? + private var downloadProgressCancellable: AnyCancellable? + private var uploadProgressCancellable: AnyCancellable? + private var uploadProgressBindingTask: Task? + private var uploadProgressLocalId: Int64? + private var uploadProgressSnapshot: UploadProgressSnapshot? private var isDownloading = false private var isPresentingViewer = false private var pendingViewerURL: URL? @@ -143,7 +147,9 @@ final class NewVideoView: UIView { } deinit { - progressCancellable?.cancel() + downloadProgressCancellable?.cancel() + uploadProgressCancellable?.cancel() + uploadProgressBindingTask?.cancel() } // MARK: - Setup @@ -267,6 +273,7 @@ final class NewVideoView: UIView { setupMask() setupGestures() updateImage() + syncUploadProgressBinding() updateDurationLabel() updateOverlay() } @@ -318,6 +325,7 @@ final class NewVideoView: UIView { let prev = self.fullMessage self.fullMessage = fullMessage updateMask() + syncUploadProgressBinding() if prev.videoInfo?.id == fullMessage.videoInfo?.id, @@ -329,8 +337,8 @@ final class NewVideoView: UIView { return } - progressCancellable?.cancel() - progressCancellable = nil + downloadProgressCancellable?.cancel() + downloadProgressCancellable = nil isDownloading = false downloadProgress = 0 downloadProgressView.setProgress(0) @@ -351,7 +359,9 @@ final class NewVideoView: UIView { private func updateOverlay() { let isVideoDownloaded = hasLocalVideoFile() - let isUploading = fullMessage.message.status == .sending && fullMessage.videoInfo?.video.cdnUrl == nil + let isUploading = isPendingUpload() + || uploadProgressSnapshot?.stage == .processing + || uploadProgressSnapshot?.stage == .uploading let globalDownloadActive = fullMessage.videoInfo .map { FileDownloader.shared.isVideoDownloadActive(videoId: $0.id) } ?? false let downloading = !isVideoDownloaded && (isDownloading || globalDownloadActive) @@ -385,6 +395,19 @@ final class NewVideoView: UIView { } private func updateDurationLabel() { + if isPendingUpload(), let uploadProgressSnapshot { + durationBadge.isHidden = false + switch uploadProgressSnapshot.stage { + case .processing: + durationBadge.text = "Processing" + case .uploading, .completed: + durationBadge.text = uploadProgressLabel(uploadProgressSnapshot) + case .failed: + durationBadge.text = "Failed" + } + return + } + guard let duration = fullMessage.videoInfo?.video.duration, duration > 0 else { durationBadge.isHidden = true return @@ -405,6 +428,27 @@ final class NewVideoView: UIView { return String(format: "%d:%02d", mins, secs) } + private func uploadProgressLabel(_ progress: UploadProgressSnapshot) -> String { + if progress.totalBytes > 0 { + return "\(formatTransferBytes(progress.bytesSent))/\(formatTransferBytes(progress.totalBytes))" + } + + if progress.fractionCompleted > 0 { + let percent = Int((progress.fractionCompleted * 100).rounded()) + return "\(percent)%" + } + + return "Uploading" + } + + private func formatTransferBytes(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: max(0, bytes), countStyle: .file) + } + + private func isPendingUpload() -> Bool { + fullMessage.message.status == .sending && fullMessage.videoInfo?.video.cdnUrl == nil + } + // MARK: - Playback @objc private func handleTap() { @@ -470,15 +514,15 @@ final class NewVideoView: UIView { } private func bindProgressIfNeeded(videoId: Int64) { - guard progressCancellable == nil else { return } + guard downloadProgressCancellable == nil else { return } - progressCancellable = FileDownloader.shared.videoProgressPublisher(videoId: videoId) + downloadProgressCancellable = FileDownloader.shared.videoProgressPublisher(videoId: videoId) .receive(on: DispatchQueue.main) .sink { [weak self] progress in guard let self else { return } if let local = self.videoLocalUrl(), FileManager.default.fileExists(atPath: local.path) { - self.progressCancellable = nil + self.downloadProgressCancellable = nil self.isDownloading = false self.downloadProgress = 0 self.updateOverlay() @@ -491,7 +535,7 @@ final class NewVideoView: UIView { } if progress.error != nil || progress.isComplete { - self.progressCancellable = nil + self.downloadProgressCancellable = nil self.isDownloading = false self.downloadProgress = 0 self.updateOverlay() @@ -502,14 +546,54 @@ final class NewVideoView: UIView { @objc private func handleCancelDownload() { guard let videoId = fullMessage.videoInfo?.id else { return } FileDownloader.shared.cancelVideoDownload(videoId: videoId) - progressCancellable?.cancel() - progressCancellable = nil + downloadProgressCancellable?.cancel() + downloadProgressCancellable = nil isDownloading = false downloadProgress = 0 downloadProgressView.setProgress(0) updateOverlay() } + private func syncUploadProgressBinding() { + guard isPendingUpload(), let videoLocalId = fullMessage.videoInfo?.video.id else { + clearUploadProgressBinding(resetState: true) + return + } + + if uploadProgressLocalId == videoLocalId, (uploadProgressCancellable != nil || uploadProgressBindingTask != nil) { + return + } + + clearUploadProgressBinding(resetState: false) + uploadProgressLocalId = videoLocalId + uploadProgressBindingTask = Task { @MainActor [weak self] in + guard let self else { return } + let publisher = await FileUploader.shared.videoProgressPublisher(videoLocalId: videoLocalId) + guard !Task.isCancelled, self.uploadProgressLocalId == videoLocalId else { return } + + self.uploadProgressBindingTask = nil + self.uploadProgressCancellable = publisher + .receive(on: DispatchQueue.main) + .sink { [weak self] progress in + guard let self else { return } + self.uploadProgressSnapshot = progress + self.updateDurationLabel() + self.updateOverlay() + } + } + } + + private func clearUploadProgressBinding(resetState: Bool) { + uploadProgressBindingTask?.cancel() + uploadProgressBindingTask = nil + uploadProgressCancellable?.cancel() + uploadProgressCancellable = nil + uploadProgressLocalId = nil + if resetState { + uploadProgressSnapshot = nil + } + } + private func videoLocalUrl() -> URL? { if let localPath = fullMessage.videoInfo?.video.localPath { return FileCache.getUrl(for: .videos, localPath: localPath) diff --git a/apple/InlineKit/Sources/InlineKit/ApiClient.swift b/apple/InlineKit/Sources/InlineKit/ApiClient.swift index d2231d8c..d3d5cc3c 100644 --- a/apple/InlineKit/Sources/InlineKit/ApiClient.swift +++ b/apple/InlineKit/Sources/InlineKit/ApiClient.swift @@ -73,10 +73,23 @@ public final class ApiClient: ObservableObject, @unchecked Sendable { public init() {} private let log = Log.scoped("ApiClient") + + public struct UploadTransferProgress: Sendable, Equatable { + public let bytesSent: Int64 + public let totalBytes: Int64 + public let fractionCompleted: Double + + public init(bytesSent: Int64, totalBytes: Int64, fractionCompleted: Double) { + self.bytesSent = max(0, bytesSent) + self.totalBytes = max(0, totalBytes) + self.fractionCompleted = min(max(fractionCompleted, 0), 1) + } + } + 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 } @@ -87,9 +100,16 @@ public final class ApiClient: ObservableObject, @unchecked Sendable { totalBytesSent: Int64, totalBytesExpectedToSend: Int64 ) { - guard totalBytesExpectedToSend > 0 else { return } - let fraction = Double(totalBytesSent) / Double(totalBytesExpectedToSend) - progressHandler(min(max(fraction, 0), 1)) + let fraction = totalBytesExpectedToSend > 0 + ? Double(totalBytesSent) / Double(totalBytesExpectedToSend) + : 0 + progressHandler( + UploadTransferProgress( + bytesSent: totalBytesSent, + totalBytes: totalBytesExpectedToSend, + fractionCompleted: fraction + ) + ) } } @@ -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 totalBodyBytes = Int64(multipartFormData.body.count) + progress(UploadTransferProgress(bytesSent: 0, totalBytes: totalBodyBytes, fractionCompleted: 0)) 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: totalBodyBytes, totalBytes: totalBodyBytes, fractionCompleted: 1)) return data case let .error(error, errorCode, description): log.error("Error \(error): \(description ?? "")") diff --git a/apple/InlineKit/Sources/InlineKit/Files/FileUpload.swift b/apple/InlineKit/Sources/InlineKit/Files/FileUpload.swift index 7b157a1b..74c2afe9 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 @@ -23,14 +24,91 @@ private struct UploadTaskInfo { let task: Task let priority: TaskPriority let startTime: Date - var progress: Double = 0 + var progress: UploadProgressSnapshot +} + +public enum UploadProgressStage: String, Sendable, Equatable { + case processing + case uploading + case completed + case failed +} + +public struct UploadProgressSnapshot: Sendable, Equatable { + public let id: String + public let stage: UploadProgressStage + public let bytesSent: Int64 + public let totalBytes: Int64 + public let fractionCompleted: Double + public let errorDescription: String? + + private init( + id: String, + stage: UploadProgressStage, + bytesSent: Int64, + totalBytes: Int64, + fractionCompleted: Double, + errorDescription: String? = nil + ) { + self.id = id + self.stage = stage + self.bytesSent = max(0, bytesSent) + self.totalBytes = max(0, totalBytes) + self.fractionCompleted = min(max(fractionCompleted, 0), 1) + self.errorDescription = errorDescription + } + + public static func processing(id: String) -> UploadProgressSnapshot { + UploadProgressSnapshot( + id: id, + stage: .processing, + bytesSent: 0, + totalBytes: 0, + fractionCompleted: 0 + ) + } + + public static func uploading(id: String, bytesSent: Int64, totalBytes: Int64) -> UploadProgressSnapshot { + let clampedTotal = max(0, totalBytes) + let clampedBytes = min(max(0, bytesSent), clampedTotal) + let fraction = clampedTotal > 0 ? Double(clampedBytes) / Double(clampedTotal) : 0 + return UploadProgressSnapshot( + id: id, + stage: .uploading, + bytesSent: clampedBytes, + totalBytes: clampedTotal, + fractionCompleted: fraction + ) + } + + public static func completed(id: String, totalBytes: Int64) -> UploadProgressSnapshot { + let clampedTotal = max(0, totalBytes) + return UploadProgressSnapshot( + id: id, + stage: .completed, + bytesSent: clampedTotal, + totalBytes: clampedTotal, + fractionCompleted: 1 + ) + } + + public static func failed(id: String, error: Error?) -> UploadProgressSnapshot { + UploadProgressSnapshot( + id: id, + stage: .failed, + bytesSent: 0, + totalBytes: 0, + fractionCompleted: 0, + errorDescription: error?.localizedDescription + ) + } } public enum UploadStatus { case notFound - case processing - case inProgress(progress: Double) + case inProgress(UploadProgressSnapshot) case completed + case failed } public actor FileUploader { @@ -39,7 +117,9 @@ public actor FileUploader { // Replace simple dictionaries with more structured storage private var uploadTasks: [String: UploadTaskInfo] = [:] private var finishedUploads: [String: UploadResult] = [:] - private var progressHandlers: [String: @Sendable (Double) -> Void] = [:] + private var progressHandlers: [String: @Sendable (UploadProgressSnapshot) -> Void] = [:] + private var progressPublishers: [String: CurrentValueSubject] = [:] + private var latestProgress: [String: UploadProgressSnapshot] = [:] private var cleanupTasks: [String: Task] = [:] private init() {} @@ -51,11 +131,14 @@ public actor FileUploader { task: Task, priority: TaskPriority = .userInitiated ) { + let initialProgress = latestProgress[uploadId] ?? .processing(id: uploadId) uploadTasks[uploadId] = UploadTaskInfo( task: task, priority: priority, - startTime: Date() + startTime: Date(), + progress: initialProgress ) + publishProgress(uploadId: uploadId, progress: initialProgress) // Setup cleanup task cleanupTasks[uploadId] = Task { [weak self] in @@ -70,6 +153,10 @@ public actor FileUploader { private func handleTaskCompletion(uploadId: String) { Log.shared.debug("[FileUploader] Upload task completed for \(uploadId)") + if let latest = latestProgress[uploadId], latest.stage != .completed { + let totalBytes = max(latest.totalBytes, latest.bytesSent) + publishProgress(uploadId: uploadId, progress: .completed(id: uploadId, totalBytes: totalBytes)) + } uploadTasks.removeValue(forKey: uploadId) cleanupTasks.removeValue(forKey: uploadId) progressHandlers.removeValue(forKey: uploadId) @@ -80,6 +167,7 @@ public actor FileUploader { "[FileUploader] Upload task failed for \(uploadId)", error: error ) + publishProgress(uploadId: uploadId, progress: .failed(id: uploadId, error: error)) uploadTasks.removeValue(forKey: uploadId) cleanupTasks.removeValue(forKey: uploadId) progressHandlers.removeValue(forKey: uploadId) @@ -87,33 +175,83 @@ public actor FileUploader { // MARK: - Progress Tracking - private func updateProgress(uploadId: String, progress: Double) { + private func updateProgress(uploadId: String, progress: UploadProgressSnapshot) { + publishProgress(uploadId: uploadId, progress: progress) + } + + private func publishProgress(uploadId: String, progress: UploadProgressSnapshot) { + latestProgress[uploadId] = progress if var taskInfo = uploadTasks[uploadId] { taskInfo.progress = progress 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) - } + } + + if let handler = progressHandlers[uploadId] { + Task { @MainActor in + await MainActor.run { + handler(progress) } } } + + if let publisher = progressPublishers[uploadId] { + publisher.send(progress) + } } - public func setProgressHandler(for uploadId: String, handler: @escaping @Sendable (Double) -> Void) { + private func progressPublisher(for uploadId: String) -> CurrentValueSubject { + if let existing = progressPublishers[uploadId] { + return existing + } + + let initialProgress = latestProgress[uploadId] ?? .processing(id: uploadId) + let publisher = CurrentValueSubject(initialProgress) + progressPublishers[uploadId] = publisher + return publisher + } + + public func videoProgressPublisher(videoLocalId: Int64) -> AnyPublisher { + progressPublisher(for: getUploadId(videoId: videoLocalId)).eraseToAnyPublisher() + } + + public func documentProgressPublisher(documentLocalId: Int64) -> AnyPublisher { + progressPublisher(for: getUploadId(documentId: documentLocalId)).eraseToAnyPublisher() + } + + public func photoProgressPublisher(photoLocalId: Int64) -> AnyPublisher { + progressPublisher(for: getUploadId(photoId: photoLocalId)).eraseToAnyPublisher() + } + + public func setUploadProgressHandler( + for uploadId: String, + handler: @escaping @Sendable (UploadProgressSnapshot) -> Void + ) { progressHandlers[uploadId] = handler - // Immediately report current progress if available - if let taskInfo = uploadTasks[uploadId] { + + let currentProgress = latestProgress[uploadId] ?? uploadTasks[uploadId]?.progress + if let currentProgress { Task { @MainActor in await MainActor.run { - handler(taskInfo.progress) + handler(currentProgress) } } } } + // Legacy API preserved for existing call sites that only understand fraction and -1 processing sentinel. + public func setProgressHandler(for uploadId: String, handler: @escaping @Sendable (Double) -> Void) { + setUploadProgressHandler(for: uploadId) { progress in + switch progress.stage { + case .processing: + handler(-1) + case .uploading, .completed: + handler(progress.fractionCompleted) + case .failed: + handler(0) + } + } + } + // MARK: - Upload Methods public func uploadPhoto( @@ -135,14 +273,7 @@ public actor FileUploader { 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 - } - } - } + publishProgress(uploadId: uploadId, progress: .processing(id: uploadId)) try startUpload( media: .photo(photoInfo), @@ -181,14 +312,7 @@ public actor FileUploader { ) let uploadId = getUploadId(videoId: localVideoId) - - if let handler = progressHandlers[uploadId] { - Task { @MainActor in - await MainActor.run { - handler(-1) - } - } - } + publishProgress(uploadId: uploadId, progress: .processing(id: uploadId)) let thumbnailPayload = try? thumbnailData(from: resolvedVideoInfo.thumbnail) let videoMetadata = ApiClient.VideoUploadMetadata( @@ -268,8 +392,10 @@ public actor FileUploader { if finishedUploads[uploadId] != nil { Log.shared.warning("[FileUploader] Upload already completed for \(uploadId)") - //throw FileUploadError.uploadAlreadyCompleted - return + let latest = latestProgress[uploadId] + let totalBytes = max(latest?.totalBytes ?? 0, latest?.bytesSent ?? 0) + publishProgress(uploadId: uploadId, progress: .completed(id: uploadId, totalBytes: totalBytes)) + return } let metadata = videoMetadata @@ -300,35 +426,73 @@ public actor FileUploader { ) async throws -> UploadResult { Log.shared.debug("[FileUploader] Starting upload for \(uploadId)") - // Compress image if it's a photo - let uploadUrl: URL - if case .photo = media { + var uploadUrl = localUrl + var uploadMimeType = mimeType + var uploadFileName = fileName + var resolvedVideoMetadata = videoMetadata + var temporaryArtifacts: [URL] = [] + + switch media { + case .photo: + publishProgress(uploadId: uploadId, progress: .processing(id: uploadId)) do { - let options = mimeType.lowercased().contains("png") ? - ImageCompressionOptions.defaultPNG : - ImageCompressionOptions.defaultPhoto + let options = mimeType.lowercased().contains("png") + ? ImageCompressionOptions.defaultPNG + : ImageCompressionOptions.defaultPhoto uploadUrl = try await ImageCompressor.shared.compressImage(at: localUrl, options: options) - + if uploadUrl != localUrl { + temporaryArtifacts.append(uploadUrl) + } } catch { // Fallback to original URL if compression fails uploadUrl = localUrl } - } else { - uploadUrl = localUrl + case .video: + let prepared = try await prepareVideoForUpload( + uploadId: uploadId, + localUrl: localUrl, + inputMimeType: mimeType, + metadata: videoMetadata + ) + uploadUrl = prepared.url + uploadMimeType = prepared.mimeType + uploadFileName = prepared.fileName + resolvedVideoMetadata = prepared.metadata + if prepared.cleanupAfterUpload { + temporaryArtifacts.append(prepared.url) + } + case .document: + break } + defer { + for url in temporaryArtifacts where FileManager.default.fileExists(atPath: url.path) { + try? FileManager.default.removeItem(at: url) + } + } + + try Task.checkCancellation() + // get data from file let data = try Data(contentsOf: uploadUrl) + let uploadSizeBytes = Int64(data.count) + publishProgress( + uploadId: uploadId, + progress: .uploading(id: uploadId, bytesSent: 0, totalBytes: uploadSizeBytes) + ) // upload file with progress tracking - let progressHandler = FileUploader.progressHandler(for: uploadId) + let progressHandler = FileUploader.progressHandler( + for: uploadId, + logicalTotalBytes: uploadSizeBytes + ) let result = try await ApiClient.shared.uploadFile( type: type, data: data, - filename: fileName, - mimeType: MIMEType(text: mimeType), - videoMetadata: videoMetadata, + filename: uploadFileName, + mimeType: MIMEType(text: uploadMimeType), + videoMetadata: resolvedVideoMetadata, progress: progressHandler ) @@ -348,6 +512,7 @@ public actor FileUploader { // Store result after successful database update storeUploadResult(uploadId: uploadId, result: result_) + publishProgress(uploadId: uploadId, progress: .completed(id: uploadId, totalBytes: uploadSizeBytes)) } catch { Log.shared.error( "[FileUploader] Failed to update database with new server ID for \(uploadId)", @@ -370,6 +535,7 @@ public actor FileUploader { if let taskInfo = uploadTasks[uploadId] { taskInfo.task.cancel() + publishProgress(uploadId: uploadId, progress: .failed(id: uploadId, error: FileUploadError.uploadCancelled)) uploadTasks.removeValue(forKey: uploadId) cleanupTasks.removeValue(forKey: uploadId) progressHandlers.removeValue(forKey: uploadId) @@ -383,8 +549,9 @@ 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() + publishProgress(uploadId: uploadId, progress: .failed(id: uploadId, error: FileUploadError.uploadCancelled)) } uploadTasks.removeAll() @@ -395,13 +562,22 @@ public actor FileUploader { // MARK: - Status Queries public func getUploadStatus(for uploadId: String) -> UploadStatus { - if let taskInfo = uploadTasks[uploadId] { - .inProgress(progress: taskInfo.progress) - } else if finishedUploads[uploadId] != nil { - .completed - } else { - .notFound + if let latest = latestProgress[uploadId] { + switch latest.stage { + case .processing, .uploading: + return .inProgress(latest) + case .completed: + return .completed + case .failed: + return .failed + } } + + if finishedUploads[uploadId] != nil { + return .completed + } + + return .notFound } // MARK: - Database Updates @@ -453,6 +629,96 @@ public actor FileUploader { "document_\(documentId)" } + private struct PreparedVideoUploadPayload { + let url: URL + let fileName: String + let mimeType: String + let metadata: ApiClient.VideoUploadMetadata + let cleanupAfterUpload: Bool + } + + private func prepareVideoForUpload( + uploadId: String, + localUrl: URL, + inputMimeType: String, + metadata: ApiClient.VideoUploadMetadata? + ) async throws -> PreparedVideoUploadPayload { + publishProgress(uploadId: uploadId, progress: .processing(id: uploadId)) + + let resolvedMetadata = try await resolveVideoUploadMetadata( + localUrl: localUrl, + fallback: metadata + ) + + do { + let result = try await VideoCompressor.shared.compressVideo( + at: localUrl, + options: VideoCompressionOptions.uploadDefault() + ) + let compressedMetadata = ApiClient.VideoUploadMetadata( + width: result.width, + height: result.height, + duration: result.duration, + thumbnail: resolvedMetadata.thumbnail, + thumbnailMimeType: resolvedMetadata.thumbnailMimeType + ) + return PreparedVideoUploadPayload( + url: result.url, + fileName: "\(UUID().uuidString).mp4", + mimeType: "video/mp4", + metadata: compressedMetadata, + cleanupAfterUpload: true + ) + } catch is CancellationError { + throw FileUploadError.uploadCancelled + } catch VideoCompressionError.compressionNotNeeded { + return PreparedVideoUploadPayload( + url: localUrl, + fileName: localUrl.lastPathComponent, + mimeType: inputMimeType, + metadata: resolvedMetadata, + cleanupAfterUpload: false + ) + } catch VideoCompressionError.compressionNotEffective { + return PreparedVideoUploadPayload( + url: localUrl, + fileName: localUrl.lastPathComponent, + mimeType: inputMimeType, + metadata: resolvedMetadata, + cleanupAfterUpload: false + ) + } catch { + Log.shared.warning( + "[FileUploader] Video preprocessing failed for \(uploadId); uploading original (\(error.localizedDescription))" + ) + return PreparedVideoUploadPayload( + url: localUrl, + fileName: localUrl.lastPathComponent, + mimeType: inputMimeType, + metadata: resolvedMetadata, + cleanupAfterUpload: false + ) + } + } + + private func resolveVideoUploadMetadata( + localUrl: URL, + fallback: ApiClient.VideoUploadMetadata? + ) async throws -> ApiClient.VideoUploadMetadata { + if let fallback, fallback.width > 0, fallback.height > 0, fallback.duration > 0 { + return fallback + } + + let (width, height, duration) = try await readVideoMetadata(from: localUrl) + return ApiClient.VideoUploadMetadata( + width: width, + height: height, + duration: duration, + thumbnail: fallback?.thumbnail, + thumbnailMimeType: fallback?.thumbnailMimeType + ) + } + private func resolveLocalVideoId(for video: Video) throws -> Int64 { if let id = video.id { return id } @@ -471,14 +737,40 @@ public actor FileUploader { } // Nonisolated helper so progress closures don't capture actor-isolated state - nonisolated static func progressHandler(for uploadId: String) -> @Sendable (Double) -> Void { - return { progress in + nonisolated static func progressHandler( + for uploadId: String, + logicalTotalBytes: Int64 + ) -> @Sendable (ApiClient.UploadTransferProgress) -> Void { + return { transferProgress in + let snapshot = mapTransferProgress( + uploadId: uploadId, + transferProgress: transferProgress, + logicalTotalBytes: logicalTotalBytes + ) Task { - await FileUploader.shared.updateProgress(uploadId: uploadId, progress: progress) + await FileUploader.shared.updateProgress(uploadId: uploadId, progress: snapshot) } } } + nonisolated static func mapTransferProgress( + uploadId: String, + transferProgress: ApiClient.UploadTransferProgress, + logicalTotalBytes: Int64 + ) -> UploadProgressSnapshot { + let clampedTotal = max(logicalTotalBytes, 0) + let clampedFraction = min(max(transferProgress.fractionCompleted, 0), 1) + + if clampedTotal > 0 { + let bytesSent = Int64((Double(clampedTotal) * clampedFraction).rounded(.down)) + return .uploading(id: uploadId, bytesSent: bytesSent, totalBytes: clampedTotal) + } + + let transferTotal = max(transferProgress.totalBytes, transferProgress.bytesSent) + let clampedBytes = min(max(0, transferProgress.bytesSent), transferTotal) + return .uploading(id: uploadId, bytesSent: clampedBytes, totalBytes: transferTotal) + } + private func thumbnailData(from photoInfo: PhotoInfo?) throws -> (Data, MIMEType)? { guard let photoInfo, @@ -503,21 +795,10 @@ public actor FileUploader { // Fallback to reading from the file if any value is missing/zero if width == 0 || height == 0 || duration == 0 { - let asset = AVURLAsset(url: localUrl) - let tracks = try await asset.loadTracks(withMediaType: .video) - if let track = tracks.first { - let naturalSize = try await track.load(.naturalSize) - let transform = try await track.load(.preferredTransform) - let transformedSize = naturalSize.applying(transform) - width = Int(abs(transformedSize.width.rounded())) - height = Int(abs(transformedSize.height.rounded())) - } - - let durationTime = try await asset.load(.duration) - let seconds = CMTimeGetSeconds(durationTime) - if seconds.isFinite { - duration = Int(seconds.rounded()) - } + let fileMetadata = try await readVideoMetadata(from: localUrl) + width = fileMetadata.0 + height = fileMetadata.1 + duration = fileMetadata.2 } // Guard against missing metadata because the server requires them @@ -528,6 +809,30 @@ public actor FileUploader { return (width, height, duration) } + private func readVideoMetadata(from localUrl: URL) async throws -> (Int, Int, Int) { + let asset = AVURLAsset(url: localUrl) + let tracks = try await asset.loadTracks(withMediaType: .video) + guard let track = tracks.first else { + throw FileUploadError.invalidVideoMetadata + } + + let naturalSize = try await track.load(.naturalSize) + let transform = try await track.load(.preferredTransform) + let transformedSize = naturalSize.applying(transform) + let width = Int(abs(transformedSize.width.rounded())) + let height = Int(abs(transformedSize.height.rounded())) + + let durationTime = try await asset.load(.duration) + let seconds = CMTimeGetSeconds(durationTime) + let duration = Int(seconds.rounded()) + + guard width > 0, height > 0, duration > 0 else { + throw FileUploadError.invalidVideoMetadata + } + + return (width, height, duration) + } + // MARK: - Wait for Upload public func waitForUpload(photoLocalId id: Int64) async throws -> UploadResult? { diff --git a/apple/InlineKit/Sources/InlineKit/Files/VideoCompression.swift b/apple/InlineKit/Sources/InlineKit/Files/VideoCompression.swift index 539f4f8f..961beabb 100644 --- a/apple/InlineKit/Sources/InlineKit/Files/VideoCompression.swift +++ b/apple/InlineKit/Sources/InlineKit/Files/VideoCompression.swift @@ -19,10 +19,10 @@ public struct VideoCompressionOptions: Sendable { public static func uploadDefault(forceTranscode: Bool = false) -> VideoCompressionOptions { VideoCompressionOptions( - maxDimension: 960, - minFileSizeBytes: 4_000_000, - maxBitrateMbps: 4.5, - minimumCompressionRatio: 0.9, + maxDimension: 1_280, + minFileSizeBytes: 8_000_000, + maxBitrateMbps: 3.0, + minimumCompressionRatio: 0.94, forceTranscode: forceTranscode ) } @@ -170,17 +170,23 @@ public actor VideoCompressor { exportSession.shouldOptimizeForNetworkUse = true let sessionBox = ExportSessionBox(exportSession) - try await withCheckedThrowingContinuation { continuation in - sessionBox.session.exportAsynchronously { - switch sessionBox.session.status { - case .completed: - continuation.resume() - case .failed, .cancelled: - continuation.resume(throwing: sessionBox.session.error ?? VideoCompressionError.exportFailed) - default: - continuation.resume(throwing: VideoCompressionError.exportFailed) + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + sessionBox.session.exportAsynchronously { + switch sessionBox.session.status { + case .completed: + continuation.resume() + case .cancelled: + continuation.resume(throwing: CancellationError()) + case .failed: + continuation.resume(throwing: sessionBox.session.error ?? VideoCompressionError.exportFailed) + default: + continuation.resume(throwing: VideoCompressionError.exportFailed) + } } } + } onCancel: { + sessionBox.session.cancelExport() } } diff --git a/apple/InlineKit/Tests/InlineKitTests/FileUploadProgressTests.swift b/apple/InlineKit/Tests/InlineKitTests/FileUploadProgressTests.swift new file mode 100644 index 00000000..d7c3dc19 --- /dev/null +++ b/apple/InlineKit/Tests/InlineKitTests/FileUploadProgressTests.swift @@ -0,0 +1,72 @@ +import Testing +@testable import InlineKit + +@Suite("File Upload Progress") +struct FileUploadProgressTests { + @Test("uploading snapshot clamps bytes and computes fraction") + func testUploadingSnapshotClamp() { + let snapshot = UploadProgressSnapshot.uploading( + id: "video_1", + bytesSent: 2_500, + totalBytes: 2_000 + ) + + #expect(snapshot.stage == .uploading) + #expect(snapshot.bytesSent == 2_000) + #expect(snapshot.totalBytes == 2_000) + #expect(snapshot.fractionCompleted == 1.0) + } + + @Test("transport progress clamps invalid values") + func testTransferProgressClamp() { + let progress = ApiClient.UploadTransferProgress( + bytesSent: -5, + totalBytes: -10, + fractionCompleted: 1.5 + ) + + #expect(progress.bytesSent == 0) + #expect(progress.totalBytes == 0) + #expect(progress.fractionCompleted == 1.0) + } + + @Test("mapTransferProgress uses logical total bytes when available") + func testMapTransferProgressUsesLogicalTotal() { + let transfer = ApiClient.UploadTransferProgress( + bytesSent: 1_000, + totalBytes: 10_000, + fractionCompleted: 0.25 + ) + + let snapshot = FileUploader.mapTransferProgress( + uploadId: "video_42", + transferProgress: transfer, + logicalTotalBytes: 2_000_000 + ) + + #expect(snapshot.stage == .uploading) + #expect(snapshot.bytesSent == 500_000) + #expect(snapshot.totalBytes == 2_000_000) + #expect(snapshot.fractionCompleted == 0.25) + } + + @Test("mapTransferProgress falls back to transport totals without logical size") + func testMapTransferProgressFallbackToTransportTotals() { + let transfer = ApiClient.UploadTransferProgress( + bytesSent: 3_000, + totalBytes: 2_000, + fractionCompleted: 0.5 + ) + + let snapshot = FileUploader.mapTransferProgress( + uploadId: "video_43", + transferProgress: transfer, + logicalTotalBytes: 0 + ) + + #expect(snapshot.stage == .uploading) + #expect(snapshot.bytesSent == 3_000) + #expect(snapshot.totalBytes == 3_000) + #expect(snapshot.fractionCompleted == 1.0) + } +} diff --git a/apple/InlineMac/Views/Message/Media/NewVideoView.swift b/apple/InlineMac/Views/Message/Media/NewVideoView.swift index a3a30689..c78246a6 100644 --- a/apple/InlineMac/Views/Message/Media/NewVideoView.swift +++ b/apple/InlineMac/Views/Message/Media/NewVideoView.swift @@ -278,7 +278,11 @@ final class NewVideoView: NSView { private var bottomRightRadius: CGFloat = Theme.messageBubbleCornerRadius - 1 private var isDownloading = false private var isShowingPreview = false - private var progressCancellable: AnyCancellable? + private var downloadProgressCancellable: AnyCancellable? + private var uploadProgressCancellable: AnyCancellable? + private var uploadProgressBindingTask: Task? + private var uploadProgressLocalId: Int64? + private var uploadProgressSnapshot: UploadProgressSnapshot? private var suppressNextClick = false private enum ActiveTransfer { case uploading(videoLocalId: Int64, transactionId: String?, randomId: Int64?) @@ -331,6 +335,7 @@ final class NewVideoView: NSView { self.fullMessage = fullMessage updateCornerRadii() updateMasks() + syncUploadProgressBinding() if prev.videoInfo?.id == fullMessage.videoInfo?.id, @@ -339,6 +344,7 @@ final class NewVideoView: NSView { { // Even if the thumbnail is unchanged, refresh overlay to reflect download/upload state changes. refreshDownloadFlags() + updateDurationLabel() updateOverlay() return } @@ -395,6 +401,7 @@ final class NewVideoView: NSView { updateImage() setupMasks() setupClickGesture() + syncUploadProgressBinding() updateDurationLabel() updateOverlay() } @@ -648,6 +655,20 @@ final class NewVideoView: NSView { } private func updateDurationLabel() { + if isPendingUpload(), let uploadProgressSnapshot { + durationBadge.isHidden = false + durationBadgeBackground.isHidden = false + switch uploadProgressSnapshot.stage { + case .processing: + durationBadge.stringValue = "Processing" + case .uploading, .completed: + durationBadge.stringValue = uploadProgressLabel(uploadProgressSnapshot) + case .failed: + durationBadge.stringValue = "Failed" + } + return + } + guard let durationSeconds = fullMessage.videoInfo?.video.duration, durationSeconds > 0 else { durationBadge.isHidden = true durationBadgeBackground.isHidden = true @@ -671,6 +692,27 @@ final class NewVideoView: NSView { } } + private func uploadProgressLabel(_ progress: UploadProgressSnapshot) -> String { + if progress.totalBytes > 0 { + return "\(formatTransferBytes(progress.bytesSent))/\(formatTransferBytes(progress.totalBytes))" + } + + if progress.fractionCompleted > 0 { + let percent = Int((progress.fractionCompleted * 100).rounded()) + return "\(percent)%" + } + + return "Uploading" + } + + private func formatTransferBytes(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: max(0, bytes), countStyle: .file) + } + + private func isPendingUpload() -> Bool { + fullMessage.message.status == .sending && fullMessage.videoInfo?.video.cdnUrl == nil + } + private func updateOverlay() { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.updateOverlay() } @@ -689,11 +731,13 @@ final class NewVideoView: NSView { if isVideoDownloaded { // Prevent any new download attempts once we have the file locally. isDownloading = false - progressCancellable?.cancel() - progressCancellable = nil + downloadProgressCancellable?.cancel() + downloadProgressCancellable = nil } - let isUploading = fullMessage.message.status == .sending && fullMessage.videoInfo?.video.cdnUrl == nil + let isUploading = isPendingUpload() + || uploadProgressSnapshot?.stage == .processing + || uploadProgressSnapshot?.stage == .uploading let globalDownloadActive = fullMessage.videoInfo .map { FileDownloader.shared.isVideoDownloadActive(videoId: $0.id) } ?? false @@ -733,6 +777,46 @@ final class NewVideoView: NSView { } } + private func syncUploadProgressBinding() { + guard isPendingUpload(), let videoLocalId = fullMessage.videoInfo?.video.id else { + clearUploadProgressBinding(resetState: true) + return + } + + if uploadProgressLocalId == videoLocalId, (uploadProgressCancellable != nil || uploadProgressBindingTask != nil) { + return + } + + clearUploadProgressBinding(resetState: false) + uploadProgressLocalId = videoLocalId + uploadProgressBindingTask = Task { @MainActor [weak self] in + guard let self else { return } + let publisher = await FileUploader.shared.videoProgressPublisher(videoLocalId: videoLocalId) + guard !Task.isCancelled, self.uploadProgressLocalId == videoLocalId else { return } + + self.uploadProgressBindingTask = nil + self.uploadProgressCancellable = publisher + .receive(on: DispatchQueue.main) + .sink { [weak self] progress in + guard let self else { return } + self.uploadProgressSnapshot = progress + self.updateDurationLabel() + self.updateOverlay() + } + } + } + + private func clearUploadProgressBinding(resetState: Bool) { + uploadProgressBindingTask?.cancel() + uploadProgressBindingTask = nil + uploadProgressCancellable?.cancel() + uploadProgressCancellable = nil + uploadProgressLocalId = nil + if resetState { + uploadProgressSnapshot = nil + } + } + // MARK: - Click / Preview private func setupClickGesture() { @@ -823,7 +907,9 @@ final class NewVideoView: NSView { } deinit { - progressCancellable?.cancel() + downloadProgressCancellable?.cancel() + uploadProgressCancellable?.cancel() + uploadProgressBindingTask?.cancel() } private func cancelActiveTransfer() { @@ -834,8 +920,8 @@ final class NewVideoView: NSView { case let .downloading(videoId): FileDownloader.shared.cancelVideoDownload(videoId: videoId) isDownloading = false - progressCancellable?.cancel() - progressCancellable = nil + downloadProgressCancellable?.cancel() + downloadProgressCancellable = nil updateOverlay() case let .uploading(videoLocalId, transactionId, randomId): @@ -876,8 +962,8 @@ final class NewVideoView: NSView { } isDownloading = false - progressCancellable?.cancel() - progressCancellable = nil + downloadProgressCancellable?.cancel() + downloadProgressCancellable = nil updateOverlay() } } @@ -992,14 +1078,14 @@ extension NewVideoView { isDownloading = true updateOverlay() - progressCancellable = FileDownloader.shared.videoProgressPublisher(videoId: videoInfo.id) + downloadProgressCancellable = FileDownloader.shared.videoProgressPublisher(videoId: videoInfo.id) .receive(on: DispatchQueue.main) .sink { [weak self] progress in guard let self else { return } // Bail out early if the file landed while we were listening. if let local = self.videoLocalUrl(), FileManager.default.fileExists(atPath: local.path) { - self.progressCancellable = nil + self.downloadProgressCancellable = nil self.isDownloading = false self.updateOverlay() completion(.success(local)) @@ -1007,7 +1093,7 @@ extension NewVideoView { } if let error = progress.error { - self.progressCancellable = nil + self.downloadProgressCancellable = nil self.isDownloading = false self.updateOverlay() completion(.failure(error)) @@ -1015,7 +1101,7 @@ extension NewVideoView { } if progress.isComplete, let local = self.videoLocalUrl() { - self.progressCancellable = nil + self.downloadProgressCancellable = nil self.isDownloading = false self.updateOverlay() completion(.success(local)) From 25bc55f2399c7030f9f86597b806e94c1e2dd209 Mon Sep 17 00:00:00 2001 From: Dena Sohrabi <87666169+dena-sohrabi@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:15:20 +0330 Subject: [PATCH 2/6] Fix Swift concurrency sendability in transaction cancellation --- apple/InlineKit/Sources/RealtimeV2/Realtime/Realtime.swift | 6 ++++-- .../Sources/RealtimeV2/Transaction/Transactions.swift | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apple/InlineKit/Sources/RealtimeV2/Realtime/Realtime.swift b/apple/InlineKit/Sources/RealtimeV2/Realtime/Realtime.swift index 73c9900a..553f0ded 100644 --- a/apple/InlineKit/Sources/RealtimeV2/Realtime/Realtime.swift +++ b/apple/InlineKit/Sources/RealtimeV2/Realtime/Realtime.swift @@ -412,8 +412,10 @@ public actor RealtimeV2 { } } - public func cancelTransaction(where predicate: @escaping (TransactionWrapper) -> Bool) { - Task { await transactions.cancel(where: predicate) } + public func cancelTransaction(where predicate: @escaping @Sendable (TransactionWrapper) -> Bool) { + Task { [predicate] in + await transactions.cancel(where: predicate) + } } public func updateSyncConfig(_ config: SyncConfig) { diff --git a/apple/InlineKit/Sources/RealtimeV2/Transaction/Transactions.swift b/apple/InlineKit/Sources/RealtimeV2/Transaction/Transactions.swift index 0f31331a..787fb968 100644 --- a/apple/InlineKit/Sources/RealtimeV2/Transaction/Transactions.swift +++ b/apple/InlineKit/Sources/RealtimeV2/Transaction/Transactions.swift @@ -239,7 +239,7 @@ actor Transactions { } /// Cancel all transactions that match the predicate from the queue. - func cancel(where predicate: (TransactionWrapper) -> Bool) { + func cancel(where predicate: @Sendable (TransactionWrapper) -> Bool) { for (transactionId, wrapper) in _queue { if predicate(wrapper) { log.trace("Cancelling transaction \(transactionId) \(wrapper.transaction.debugDescription)") From d35e2761cf428145faa5d9ff96916ebf140d3c32 Mon Sep 17 00:00:00 2001 From: Dena Sohrabi <87666169+dena-sohrabi@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:22:02 +0330 Subject: [PATCH 3/6] Fix CI Swift 6.2 sendability and HomeSearch query compile --- .../Sources/InlineKit/Models/Message.swift | 26 +++++++++++-------- .../ViewModels/HomeSearchViewModel.swift | 11 ++++---- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/apple/InlineKit/Sources/InlineKit/Models/Message.swift b/apple/InlineKit/Sources/InlineKit/Models/Message.swift index ec06fcb8..093bf550 100644 --- a/apple/InlineKit/Sources/InlineKit/Models/Message.swift +++ b/apple/InlineKit/Sources/InlineKit/Models/Message.swift @@ -612,26 +612,26 @@ public extension Message { updateHasLinkIfNeeded() // Save the message - let message = try saveAndFetch(db, onConflict: .ignore) + let savedMessage = try saveAndFetch(db, onConflict: .ignore) // Publish changes if needed if publishChanges { - let message = self // Create an immutable copy + let messageForPublish = self let peer = peerId // Capture the peer value + let wasExisting = isExisting db.afterNextTransaction { _ in Task { @MainActor in - // HACKY WAY - if isExisting { - await MessagesPublisher.shared.messageUpdated(message: message, peer: peer, animated: false) + if wasExisting { + await MessagesPublisher.shared.messageUpdated(message: messageForPublish, peer: peer, animated: false) } else { - await MessagesPublisher.shared.messageAdded(message: message, peer: peer) + await MessagesPublisher.shared.messageAdded(message: messageForPublish, peer: peer) } } } } - return message + return savedMessage } } @@ -670,18 +670,20 @@ public extension ApiMessage { } if publishChanges { + let messageForPublish = message + let peer = messageForPublish.peerId // Publish changes when save is successful if isUpdate { db.afterNextTransaction { _ in Task { @MainActor in - await MessagesPublisher.shared.messageUpdated(message: message, peer: message.peerId, animated: false) + await MessagesPublisher.shared.messageUpdated(message: messageForPublish, peer: peer, animated: false) } } } else { db.afterNextTransaction { _ in // This code runs after the transaction successfully commits Task { @MainActor in - await MessagesPublisher.shared.messageAdded(message: message, peer: message.peerId) + await MessagesPublisher.shared.messageAdded(message: messageForPublish, peer: peer) } } } @@ -761,18 +763,20 @@ public extension Message { } if publishChanges { + let messageForPublish = message + let peer = messageForPublish.peerId // Publish changes when save is successful if isUpdate { db.afterNextTransaction { _ in Task { @MainActor in - await MessagesPublisher.shared.messageUpdated(message: message, peer: message.peerId, animated: false) + await MessagesPublisher.shared.messageUpdated(message: messageForPublish, peer: peer, animated: false) } } } else { db.afterNextTransaction { _ in // This code runs after the transaction successfully commits Task { @MainActor in - await MessagesPublisher.shared.messageAdded(message: message, peer: message.peerId) + await MessagesPublisher.shared.messageAdded(message: messageForPublish, peer: peer) } } } diff --git a/apple/InlineKit/Sources/InlineKit/ViewModels/HomeSearchViewModel.swift b/apple/InlineKit/Sources/InlineKit/ViewModels/HomeSearchViewModel.swift index 537ef019..906dabd4 100644 --- a/apple/InlineKit/Sources/InlineKit/ViewModels/HomeSearchViewModel.swift +++ b/apple/InlineKit/Sources/InlineKit/ViewModels/HomeSearchViewModel.swift @@ -70,6 +70,7 @@ public final class HomeSearchViewModel: ObservableObject { Task { do { + let queryPattern = "%\(trimmedQuery)%" let chats = try await db.reader.read { db in let threads = try Chat .filter { @@ -81,12 +82,10 @@ public final class HomeSearchViewModel: ObservableObject { .fetchAll(db) let users = try User - .filter { - $0.firstName.like("%\(trimmedQuery)%") || - $0.lastName.like("%\(trimmedQuery)%") || - $0.email == trimmedQuery || - $0.username == trimmedQuery - } + .filter( + sql: "firstName LIKE ? OR lastName LIKE ? OR email = ? OR username = ?", + arguments: [queryPattern, queryPattern, trimmedQuery, trimmedQuery] + ) .fetchAll(db) return threads.map { HomeSearchResultItem.thread($0) } + From f9885d368515f28bf16c7db4769200949e543f87 Mon Sep 17 00:00:00 2001 From: Dena Sohrabi <87666169+dena-sohrabi@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:27:20 +0330 Subject: [PATCH 4/6] Fix SyncTests Swift 6.2 sendability for async processing --- .../InlineKitTests/RealtimeV2/SyncTests.swift | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apple/InlineKit/Tests/InlineKitTests/RealtimeV2/SyncTests.swift b/apple/InlineKit/Tests/InlineKitTests/RealtimeV2/SyncTests.swift index ce50e09a..948f080d 100644 --- a/apple/InlineKit/Tests/InlineKitTests/RealtimeV2/SyncTests.swift +++ b/apple/InlineKit/Tests/InlineKitTests/RealtimeV2/SyncTests.swift @@ -6,7 +6,7 @@ import Testing @testable import RealtimeV2 @Suite("SyncTests") -class SyncTests { +final class SyncTests { @Test("updates lastSyncDate with safety gap") func testLastSyncDateSafetyGap() async throws { let storage = InMemorySyncStorage() @@ -57,12 +57,13 @@ class SyncTests { var update = InlineProtocol.Update() update.update = .chatHasNewUpdates(payload) - Task { await sync.process(updates: [update]) } + async let firstProcess: Void = sync.process(updates: [update]) await client.waitForFirstCallStarted() await sync.process(updates: [update]) await client.releaseFirstCall() + await firstProcess _ = await waitForCondition { await client.getCallCount() == 2 } @@ -770,7 +771,7 @@ class SyncTests { var signal = InlineProtocol.Update() signal.update = .chatHasNewUpdates(payload) - Task { await sync.process(updates: [signal]) } + async let firstProcess: Void = sync.process(updates: [signal]) await client.waitForCallStarted(2) let realtime1 = makeNewMessageUpdate(seq: 1, date: 80) @@ -778,6 +779,7 @@ class SyncTests { await sync.process(updates: [realtime1, realtime2]) await client.releaseCall(2) + await firstProcess _ = await waitForCondition { await apply.appliedUpdates.count == 2 @@ -827,13 +829,14 @@ class SyncTests { var signal = InlineProtocol.Update() signal.update = .chatHasNewUpdates(payload) - Task { await sync.process(updates: [signal]) } + async let firstProcess: Void = sync.process(updates: [signal]) await client.waitForCallStarted(2) let realtime2 = makeChatInfoUpdate(seq: 2, date: 90) await sync.process(updates: [realtime2]) await client.releaseCall(2) + await firstProcess _ = await waitForCondition { await apply.appliedUpdates.count == 2 @@ -1010,7 +1013,7 @@ class SyncTests { var signal = InlineProtocol.Update() signal.update = .chatHasNewUpdates(payload) - Task { await sync.process(updates: [signal]) } + async let firstProcess: Void = sync.process(updates: [signal]) await client.waitForCallStarted(2) let realtime4 = makeNewMessageUpdate(seq: 4, date: 110) @@ -1018,6 +1021,7 @@ class SyncTests { await sync.process(updates: [realtime4, realtime5]) await client.releaseCall(2) + await firstProcess _ = await waitForCondition { await apply.appliedUpdates.count == 5 @@ -1099,7 +1103,7 @@ class SyncTests { var signal = InlineProtocol.Update() signal.update = .chatHasNewUpdates(payload) - Task { await sync.process(updates: [signal]) } + async let firstProcess: Void = sync.process(updates: [signal]) await client.waitForFirstCallStarted() // While the fetch is in-flight (awaiting callRpc), a realtime update advances the bucket to seq=1. @@ -1107,6 +1111,7 @@ class SyncTests { await sync.process(updates: [realtimeUpdate]) await client.releaseFirstCall() + await firstProcess _ = await waitForCondition { let bucketState = await storage.getBucketState(for: .chat(peer: peer)) return bucketState.seq == 1 From ea324d0db1c37cd200cb3bc2c1107ad9c42564ef Mon Sep 17 00:00:00 2001 From: Dena Sohrabi <87666169+dena-sohrabi@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:32:58 +0330 Subject: [PATCH 5/6] Fix SyncTests sendability for shared hasNewUpdates signals --- .../InlineKitTests/RealtimeV2/SyncTests.swift | 65 +++++++------------ 1 file changed, 23 insertions(+), 42 deletions(-) diff --git a/apple/InlineKit/Tests/InlineKitTests/RealtimeV2/SyncTests.swift b/apple/InlineKit/Tests/InlineKitTests/RealtimeV2/SyncTests.swift index 948f080d..ae045008 100644 --- a/apple/InlineKit/Tests/InlineKitTests/RealtimeV2/SyncTests.swift +++ b/apple/InlineKit/Tests/InlineKitTests/RealtimeV2/SyncTests.swift @@ -49,18 +49,13 @@ final class SyncTests { let config = SyncConfig(enableMessageUpdates: false, lastSyncSafetyGapSeconds: 15) let sync = Sync(applyUpdates: apply, syncStorage: storage, client: client, config: config) - let peer = makeChatPeer(chatId: 1) - var payload = InlineProtocol.UpdateChatHasNewUpdates() - payload.peerID = peer - payload.updateSeq = 1 - - var update = InlineProtocol.Update() - update.update = .chatHasNewUpdates(payload) - - async let firstProcess: Void = sync.process(updates: [update]) + let chatId: Int64 = 1 + let firstSignal = makeChatHasNewUpdatesSignal(chatId: chatId, updateSeq: 1) + async let firstProcess: Void = sync.process(updates: [firstSignal]) await client.waitForFirstCallStarted() - await sync.process(updates: [update]) + let secondSignal = makeChatHasNewUpdatesSignal(chatId: chatId, updateSeq: 1) + await sync.process(updates: [secondSignal]) await client.releaseFirstCall() await firstProcess @@ -764,14 +759,8 @@ final class SyncTests { let sync = Sync(applyUpdates: apply, syncStorage: storage, client: client, config: config) let peer = makeChatPeer(chatId: 1) - var payload = InlineProtocol.UpdateChatHasNewUpdates() - payload.peerID = peer - payload.updateSeq = 2 - - var signal = InlineProtocol.Update() - signal.update = .chatHasNewUpdates(payload) - - async let firstProcess: Void = sync.process(updates: [signal]) + let firstSignal = makeChatHasNewUpdatesSignal(chatId: 1, updateSeq: 2) + async let firstProcess: Void = sync.process(updates: [firstSignal]) await client.waitForCallStarted(2) let realtime1 = makeNewMessageUpdate(seq: 1, date: 80) @@ -822,14 +811,8 @@ final class SyncTests { let sync = Sync(applyUpdates: apply, syncStorage: storage, client: client, config: config) let peer = makeChatPeer(chatId: 1) - var payload = InlineProtocol.UpdateChatHasNewUpdates() - payload.peerID = peer - payload.updateSeq = 2 - - var signal = InlineProtocol.Update() - signal.update = .chatHasNewUpdates(payload) - - async let firstProcess: Void = sync.process(updates: [signal]) + let firstSignal = makeChatHasNewUpdatesSignal(chatId: 1, updateSeq: 2) + async let firstProcess: Void = sync.process(updates: [firstSignal]) await client.waitForCallStarted(2) let realtime2 = makeChatInfoUpdate(seq: 2, date: 90) @@ -1006,14 +989,8 @@ final class SyncTests { let sync = Sync(applyUpdates: apply, syncStorage: storage, client: client, config: config) let peer = makeChatPeer(chatId: 1) - var payload = InlineProtocol.UpdateChatHasNewUpdates() - payload.peerID = peer - payload.updateSeq = 5 - - var signal = InlineProtocol.Update() - signal.update = .chatHasNewUpdates(payload) - - async let firstProcess: Void = sync.process(updates: [signal]) + let firstSignal = makeChatHasNewUpdatesSignal(chatId: 1, updateSeq: 5) + async let firstProcess: Void = sync.process(updates: [firstSignal]) await client.waitForCallStarted(2) let realtime4 = makeNewMessageUpdate(seq: 4, date: 110) @@ -1096,14 +1073,8 @@ final class SyncTests { let sync = Sync(applyUpdates: apply, syncStorage: storage, client: client, config: config) let peer = makeChatPeer(chatId: 1) - var payload = InlineProtocol.UpdateChatHasNewUpdates() - payload.peerID = peer - payload.updateSeq = 1 - - var signal = InlineProtocol.Update() - signal.update = .chatHasNewUpdates(payload) - - async let firstProcess: Void = sync.process(updates: [signal]) + let firstSignal = makeChatHasNewUpdatesSignal(chatId: 1, updateSeq: 1) + async let firstProcess: Void = sync.process(updates: [firstSignal]) await client.waitForFirstCallStarted() // While the fetch is in-flight (awaiting callRpc), a realtime update advances the bucket to seq=1. @@ -1361,6 +1332,16 @@ private func makeChatPeer(chatId: Int64) -> InlineProtocol.Peer { return peer } +private func makeChatHasNewUpdatesSignal(chatId: Int64, updateSeq: Int32) -> InlineProtocol.Update { + var payload = InlineProtocol.UpdateChatHasNewUpdates() + payload.peerID = makeChatPeer(chatId: chatId) + payload.updateSeq = updateSeq + + var update = InlineProtocol.Update() + update.update = .chatHasNewUpdates(payload) + return update +} + private func makeGetUpdatesResult( seq: Int64, date: Int64, From 672fb05892dec4fc29c5bc04aac912802a2cc997 Mon Sep 17 00:00:00 2001 From: Dena Sohrabi <87666169+dena-sohrabi@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:55:56 +0330 Subject: [PATCH 6/6] Fix tab strip constraint conflicts --- .../TabStripCollectionViewItem.swift | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/apple/InlineMacUI/Sources/InlineMacTabStrip/TabStripCollectionViewItem.swift b/apple/InlineMacUI/Sources/InlineMacTabStrip/TabStripCollectionViewItem.swift index 349208d6..4a7be16a 100644 --- a/apple/InlineMacUI/Sources/InlineMacTabStrip/TabStripCollectionViewItem.swift +++ b/apple/InlineMacUI/Sources/InlineMacTabStrip/TabStripCollectionViewItem.swift @@ -58,6 +58,11 @@ final class TabStripCollectionViewItem: NSCollectionViewItem, TabStripItemHoverD private let titleMaskLayer = CAGradientLayer() private let closeOverlay = TabStripCloseOverlayView() + private enum LayoutMode { + case home + case standard + } + override init(nibName _: NSNib.Name?, bundle _: Bundle?) { super.init(nibName: nil, bundle: nil) setupViews() @@ -167,7 +172,6 @@ final class TabStripCollectionViewItem: NSCollectionViewItem, TabStripItemHoverD ), iconWidthConstraint, iconHeightConstraint, - iconCenterXForHome, titleLeadingConstraint, titleLabel.centerYAnchor.constraint( @@ -186,6 +190,8 @@ final class TabStripCollectionViewItem: NSCollectionViewItem, TabStripItemHoverD closeOverlay.setContentHuggingPriority(.required, for: .horizontal) titleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + + applyLayoutMode(.standard) } func configure( @@ -208,10 +214,9 @@ final class TabStripCollectionViewItem: NSCollectionViewItem, TabStripItemHoverD titleLeadingConstraint.constant = isHome ? 0 : Style.iconTrailingPadding * paddingScale iconLeadingConstraint.constant = Style.iconLeadingPadding * paddingScale - iconCenterXForHome.isActive = isHome - iconLeadingConstraint.isActive = !isHome if isHome { + applyLayoutMode(.home) iconWidthConstraint.constant = Style.homeIconPointSize iconHeightConstraint.constant = Style.homeIconPointSize iconImageView.layer?.cornerRadius = 0 @@ -229,13 +234,10 @@ final class TabStripCollectionViewItem: NSCollectionViewItem, TabStripItemHoverD iconImageView.contentTintColor = Style.homeIconTintColor titleLabel.stringValue = "" titleLabel.isHidden = true - titleLeadingConstraint.isActive = false - titleTrailingConstraint.isActive = false - iconLeadingConstraint.isActive = false - iconCenterXForHome.isActive = true closeOverlay.isHidden = true titleLabel.layer?.mask = nil } else { + applyLayoutMode(.standard) iconWidthConstraint.constant = iconSize iconHeightConstraint.constant = iconSize iconImageView.layer?.cornerRadius = iconSize / 3 @@ -247,10 +249,6 @@ final class TabStripCollectionViewItem: NSCollectionViewItem, TabStripItemHoverD iconImageView.contentTintColor = nil titleLabel.stringValue = item.title titleLabel.isHidden = false - titleLeadingConstraint.isActive = true - titleTrailingConstraint.isActive = true - iconLeadingConstraint.isActive = true - iconCenterXForHome.isActive = false titleTrailingConstraint.constant = -Style.trailingInsetDefault closeOverlay.isHidden = false titleLabel.layer?.mask = titleMaskLayer @@ -352,14 +350,34 @@ final class TabStripCollectionViewItem: NSCollectionViewItem, TabStripItemHoverD if let tabView = view as? TabStripItemView { tabView.isClosable = true } + applyLayoutMode(.standard) titleLabel.layer?.mask = titleMaskLayer - titleLeadingConstraint.isActive = true - titleTrailingConstraint.isActive = true - iconLeadingConstraint.isActive = true - iconCenterXForHome.isActive = false updateAppearance(animated: false) } + private func applyLayoutMode(_ mode: LayoutMode) { + switch mode { + case .home: + NSLayoutConstraint.deactivate([ + iconLeadingConstraint, + titleLeadingConstraint, + titleTrailingConstraint, + ]) + NSLayoutConstraint.activate([ + iconCenterXForHome, + ]) + case .standard: + NSLayoutConstraint.deactivate([ + iconCenterXForHome, + ]) + NSLayoutConstraint.activate([ + iconLeadingConstraint, + titleLeadingConstraint, + titleTrailingConstraint, + ]) + } + } + func tabHoverDidChange(isHovered: Bool) { self.isHovered = isHovered updateAppearance()