From 0e4717410f0c50fcc0b3c17c7f20ce805240a9ad Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 2 Oct 2026 08:49:16 +0200 Subject: [PATCH 1/9] Implement attachment importing --- Mactrix.xctestplan | 7 + Package.swift | 1 + .../Extensions/Logger+Extensions.swift | 1 + .../ChatInputView/ComposerAttachment.swift | 69 +++++ .../ComposerAttachmentImporter.swift | 249 ++++++++++++++++++ .../ComposerAttachmentImporterTests.swift | 163 ++++++++++++ 6 files changed, 490 insertions(+) create mode 100644 Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachment.swift create mode 100644 Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentImporter.swift create mode 100644 Tests/MactrixAppTests/ComposerAttachmentImporterTests.swift diff --git a/Mactrix.xctestplan b/Mactrix.xctestplan index 43a6c56..529e98b 100644 --- a/Mactrix.xctestplan +++ b/Mactrix.xctestplan @@ -20,6 +20,13 @@ "identifier" : "UtilsTests", "name" : "UtilsTests" } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "MactrixAppTests", + "name" : "MactrixAppTests" + } } ], "version" : 1 diff --git a/Package.swift b/Package.swift index 8ac4ff5..9254484 100644 --- a/Package.swift +++ b/Package.swift @@ -51,5 +51,6 @@ let package = Package( .product(name: "ZMarkupParser", package: "zmarkupparser") ] ), + .testTarget(name: "MactrixAppTests", dependencies: ["MactrixApp"]), ] ) diff --git a/Sources/MactrixApp/Extensions/Logger+Extensions.swift b/Sources/MactrixApp/Extensions/Logger+Extensions.swift index 75e41e3..8181846 100644 --- a/Sources/MactrixApp/Extensions/Logger+Extensions.swift +++ b/Sources/MactrixApp/Extensions/Logger+Extensions.swift @@ -7,4 +7,5 @@ extension Logger { static let matrixClient = Logger(subsystem: subsystem, category: "matrix-client") static let windowState = Logger(subsystem: subsystem, category: "window-state") static let timelineTableView = Logger(subsystem: subsystem, category: "timeline-table-view") + static let composerAttachment = Logger(subsystem: "com.mactrix.app", category: "composer-attachment") } diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachment.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachment.swift new file mode 100644 index 0000000..2b8ffb6 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachment.swift @@ -0,0 +1,69 @@ +import AppKit +import Foundation +import UniformTypeIdentifiers + +struct ComposerAttachment: Identifiable { + enum Kind: Sendable { + case image + case video + case audio + case file + } + + enum StorageOwnership: Sendable { + case externalUserFile + case appOwnedTemporaryFile + } + + enum DeduplicationIdentity: Hashable, Sendable { + case fileSystemResourceIdentifier(String) + case canonicalURL(String) + case absolutePath(String) + case contentDigest(String) + } + + let id: UUID + let sourceURL: URL + let filename: String + let contentType: UTType + let byteCount: Int64? + let kind: Kind + let storageOwnership: StorageOwnership + let deduplicationIdentity: DeduplicationIdentity + let pixelWidth: Int? + let pixelHeight: Int? + let duration: TimeInterval? + let preview: NSImage? + + var mimeType: String? { + contentType.preferredMIMEType + } + + init( + id: UUID = UUID(), + sourceURL: URL, + filename: String, + contentType: UTType, + byteCount: Int64?, + kind: Kind, + storageOwnership: StorageOwnership, + deduplicationIdentity: DeduplicationIdentity, + pixelWidth: Int? = nil, + pixelHeight: Int? = nil, + duration: TimeInterval? = nil, + preview: NSImage? = nil + ) { + self.id = id + self.sourceURL = sourceURL + self.filename = filename + self.contentType = contentType + self.byteCount = byteCount + self.kind = kind + self.storageOwnership = storageOwnership + self.deduplicationIdentity = deduplicationIdentity + self.pixelWidth = pixelWidth + self.pixelHeight = pixelHeight + self.duration = duration + self.preview = preview + } +} diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentImporter.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentImporter.swift new file mode 100644 index 0000000..fdb2a0f --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentImporter.swift @@ -0,0 +1,249 @@ +import AVFoundation +import AppKit +import CryptoKit +import Foundation +import ImageIO +import OSLog +import UniformTypeIdentifiers + +struct ComposerAttachmentImporter { + enum ImportError: Error, LocalizedError { + case notFileURL(URL) + case notReadableRegularFile(URL) + case invalidClipboardImage + case cleanupRefused(URL) + + var errorDescription: String? { + switch self { + case .notFileURL(let url): "Attachment URL is not a file URL: \(url)" + case .notReadableRegularFile(let url): + "Attachment is not a readable regular file: \(url.path)" + case .invalidClipboardImage: "Clipboard data does not contain a valid image" + case .cleanupRefused(let url): "Refusing to remove externally-owned attachment: \(url.path)" + } + } + } + + private static let temporaryDirectoryName = "MactrixComposerAttachments" + private static let previewMaximumPixelSize = 512 + + /// Imports one readable regular file. This method performs file and media work off the main actor. + static func importFile( + at url: URL, + storageOwnership: ComposerAttachment.StorageOwnership = .externalUserFile + ) async throws -> ComposerAttachment { + try await Task.detached(priority: .userInitiated) { + try await importFileSynchronously(at: url, storageOwnership: storageOwnership) + }.value + } + + /// Imports every valid URL in the supplied order. Invalid inputs are logged and do not prevent later files importing. + static func importFiles(at urls: [URL]) async -> [ComposerAttachment] { + var attachments: [ComposerAttachment] = [] + for url in urls { + do { + attachments.append(try await importFile(at: url)) + } catch { + Logger.composerAttachment.error( + "Unable to import attachment \(url.path): \(error)" + ) + } + } + return attachments + } + + /// Materializes valid PNG or TIFF clipboard data in app-owned temporary storage, then follows the normal file importer path. + static func importClipboardImage(pngData: Data?, tiffData: Data?) async throws + -> ComposerAttachment + { + let materialized = try await Task.detached(priority: .userInitiated) { + let png: Data + let digestSource: Data + if let pngData, let validPNG = try validPNGData(pngData) { + png = validPNG + digestSource = pngData + } else if let tiffData, let convertedPNG = convertTIFFToPNG(tiffData) { + png = convertedPNG + digestSource = tiffData + } else { + throw ImportError.invalidClipboardImage + } + let digest = SHA256.hash(data: digestSource).map { String(format: "%02x", $0) }.joined() + + let directory = temporaryDirectoryURL() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss_SSS" + let filename = "Clipboard Image \(formatter.string(from: Date())) \(UUID().uuidString).png" + let url = directory.appendingPathComponent(filename) + try png.write(to: url) + + return (url: url, digest: digest) + }.value + + let imported = try await importFile( + at: materialized.url, storageOwnership: .appOwnedTemporaryFile + ) + + return ComposerAttachment( + id: imported.id, sourceURL: imported.sourceURL, filename: imported.filename, + contentType: imported.contentType, byteCount: imported.byteCount, + kind: imported.kind, storageOwnership: imported.storageOwnership, + deduplicationIdentity: .contentDigest(materialized.digest), pixelWidth: imported.pixelWidth, + pixelHeight: imported.pixelHeight, duration: imported.duration, preview: imported.preview + ) + } + + static func cleanupTemporaryAttachment(_ attachment: ComposerAttachment) throws { + let temporaryDirectory = temporaryDirectoryURL().standardizedFileURL.path + "/" + let attachmentPath = attachment.sourceURL.standardizedFileURL.path + guard attachment.storageOwnership == .appOwnedTemporaryFile, + attachmentPath.hasPrefix(temporaryDirectory) + else { + throw ImportError.cleanupRefused(attachment.sourceURL) + } + try FileManager.default.removeItem(at: attachment.sourceURL) + } + + private static func temporaryDirectoryURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent(temporaryDirectoryName, isDirectory: true) + } + + private static func importFileSynchronously( + at inputURL: URL, + storageOwnership: ComposerAttachment.StorageOwnership + ) async throws -> ComposerAttachment { + guard inputURL.isFileURL else { throw ImportError.notFileURL(inputURL) } + + let url = inputURL.standardizedFileURL.resolvingSymlinksInPath() + let values = try url.resourceValues(forKeys: [ + .isRegularFileKey, .fileSizeKey, .fileResourceIdentifierKey, + ]) + guard values.isRegularFile == true, FileManager.default.isReadableFile(atPath: url.path) else { + throw ImportError.notReadableRegularFile(inputURL) + } + + let type = UTType(filenameExtension: url.pathExtension) ?? .data + let kind = attachmentKind(for: type) + let identity = deduplicationIdentity( + for: url, resourceIdentifier: values.fileResourceIdentifier) + let metadata = await mediaMetadata(for: url, kind: kind) + + return ComposerAttachment( + sourceURL: url, + filename: inputURL.lastPathComponent, + contentType: type, + byteCount: values.fileSize.map(Int64.init), + kind: kind, + storageOwnership: storageOwnership, + deduplicationIdentity: identity, + pixelWidth: metadata.width, + pixelHeight: metadata.height, + duration: metadata.duration, + preview: metadata.preview + ) + } + + private static func attachmentKind(for type: UTType) -> ComposerAttachment.Kind { + if type.conforms(to: .image) { return .image } + if type.conforms(to: .movie) || type.conforms(to: .video) { return .video } + if type.conforms(to: .audio) { return .audio } + return .file + } + + private static func deduplicationIdentity(for url: URL, resourceIdentifier: Any?) + -> ComposerAttachment.DeduplicationIdentity + { + if let resourceIdentifier { + return .fileSystemResourceIdentifier(String(describing: resourceIdentifier)) + } + let canonicalURL = url.standardizedFileURL.resolvingSymlinksInPath() + if canonicalURL.isFileURL { return .canonicalURL(canonicalURL.absoluteString) } + return .absolutePath(canonicalURL.path) + } + + private static func mediaMetadata(for url: URL, kind: ComposerAttachment.Kind) async -> ( + width: Int?, height: Int?, duration: TimeInterval?, preview: NSImage? + ) { + switch kind { + case .image: + return imageMetadata(for: url) + case .video: + let asset = AVURLAsset(url: url) + let duration = try? await asset.load(.duration).seconds + let tracks = try? await asset.loadTracks(withMediaType: .video) + let naturalSize: CGSize? + if let track = tracks?.first { + naturalSize = try? await track.load(.naturalSize) + } else { + naturalSize = nil + } + let preview = await videoPreview(for: asset) + return ( + naturalSize.map { Int($0.width) }, naturalSize.map { Int($0.height) }, + duration?.isFinite == true ? duration : nil, preview + ) + case .audio: + let asset = AVURLAsset(url: url) + let duration = try? await asset.load(.duration).seconds + return (nil, nil, duration?.isFinite == true ? duration : nil, nil) + case .file: + return (nil, nil, nil, nil) + } + } + + private static func imageMetadata(for url: URL) -> ( + width: Int?, height: Int?, duration: TimeInterval?, preview: NSImage? + ) { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { + return (nil, nil, nil, nil) + } + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] + let width = properties?[kCGImagePropertyPixelWidth] as? Int + let height = properties?[kCGImagePropertyPixelHeight] as? Int + return (width, height, nil, imagePreview(from: source)) + } + + private static func imagePreview(from source: CGImageSource) -> NSImage? { + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: previewMaximumPixelSize, + kCGImageSourceCreateThumbnailWithTransform: true, + ] + guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return nil + } + return NSImage(cgImage: image, size: .zero) + } + + private static func videoPreview(for asset: AVAsset) async -> NSImage? { + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: previewMaximumPixelSize, height: previewMaximumPixelSize) + let image = await withCheckedContinuation { continuation in + generator.generateCGImageAsynchronously(for: .zero) { image, _, _ in + continuation.resume(returning: image) + } + } + guard let image else { return nil } + return NSImage(cgImage: image, size: .zero) + } + + private static func validPNGData(_ data: Data) throws -> Data? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let type = CGImageSourceGetType(source), UTType(type as String) == .png + else { return nil } + return data + } + + private static func convertTIFFToPNG(_ data: Data) -> Data? { + guard let image = NSImage(data: data), + let tiff = image.tiffRepresentation, + let representation = NSBitmapImageRep(data: tiff) + else { return nil } + return representation.representation(using: .png, properties: [:]) + } +} diff --git a/Tests/MactrixAppTests/ComposerAttachmentImporterTests.swift b/Tests/MactrixAppTests/ComposerAttachmentImporterTests.swift new file mode 100644 index 0000000..bbc95c7 --- /dev/null +++ b/Tests/MactrixAppTests/ComposerAttachmentImporterTests.swift @@ -0,0 +1,163 @@ +import AppKit +import Foundation +import Testing + +@testable import MactrixApp + +struct ComposerAttachmentImporterTests { + @Test func regularFileIncludesSizeAndMimeMetadata() async throws { + try await withTemporaryDirectory { directory in + let url = directory.appendingPathComponent("note.txt") + try Data("hello".utf8).write(to: url) + + let attachment = try await ComposerAttachmentImporter.importFile(at: url) + + #expect(attachment.kind == .file) + #expect(attachment.byteCount == 5) + #expect(attachment.mimeType == "text/plain") + #expect(attachment.storageOwnership == .externalUserFile) + } + } + + @Test func imageIncludesDimensionsAndPreview() async throws { + try await withTemporaryDirectory { directory in + let url = directory.appendingPathComponent("picture.png") + try pngData(width: 20, height: 10).write(to: url) + + let attachment = try await ComposerAttachmentImporter.importFile(at: url) + + #expect(attachment.kind == .image) + #expect(attachment.pixelWidth == 20) + #expect(attachment.pixelHeight == 10) + #expect(attachment.preview != nil) + } + } + + @Test func audioAndVideoClassifyWhenMetadataIsUnavailable() async throws { + try await withTemporaryDirectory { directory in + let audioURL = directory.appendingPathComponent("empty.mp3") + let videoURL = directory.appendingPathComponent("empty.mov") + try Data().write(to: audioURL) + try Data().write(to: videoURL) + + let audio = try await ComposerAttachmentImporter.importFile(at: audioURL) + let video = try await ComposerAttachmentImporter.importFile(at: videoURL) + + #expect(audio.kind == .audio) + #expect(video.kind == .video) + } + } + + @Test func invalidURLsAreRejectedAndBatchImportPreservesValidOrder() async throws { + try await withTemporaryDirectory { directory in + let first = directory.appendingPathComponent("first.txt") + let second = directory.appendingPathComponent("second.txt") + try Data("first".utf8).write(to: first) + try Data("second".utf8).write(to: second) + let missing = directory.appendingPathComponent("missing.txt") + + await #expect(throws: ComposerAttachmentImporter.ImportError.self) { + try await ComposerAttachmentImporter.importFile(at: directory) + } + await #expect(throws: ComposerAttachmentImporter.ImportError.self) { + try await ComposerAttachmentImporter.importFile(at: URL(string: "https://example.com/file")!) + } + + let attachments = await ComposerAttachmentImporter.importFiles(at: [first, missing, second]) + #expect(attachments.map(\.filename) == ["first.txt", "second.txt"]) + } + } + + @Test func canonicalFilesDeduplicateButEqualNamesDoNot() async throws { + try await withTemporaryDirectory { directory in + let firstDirectory = directory.appendingPathComponent("one") + let secondDirectory = directory.appendingPathComponent("two") + try FileManager.default.createDirectory(at: firstDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: secondDirectory, withIntermediateDirectories: true) + let first = firstDirectory.appendingPathComponent("same-name.txt") + let second = secondDirectory.appendingPathComponent("same-name.txt") + try Data("one".utf8).write(to: first) + try Data("two".utf8).write(to: second) + + let firstImport = try await ComposerAttachmentImporter.importFile(at: first) + let sameFileImport = try await ComposerAttachmentImporter.importFile(at: first) + let secondImport = try await ComposerAttachmentImporter.importFile(at: second) + + #expect(firstImport.deduplicationIdentity == sameFileImport.deduplicationIdentity) + #expect(firstImport.deduplicationIdentity != secondImport.deduplicationIdentity) + } + } + + @Test func symlinkAndTargetDeduplicate() async throws { + try await withTemporaryDirectory { directory in + let target = directory.appendingPathComponent("target.txt") + let link = directory.appendingPathComponent("link.txt") + try Data("target".utf8).write(to: target) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + + let targetImport = try await ComposerAttachmentImporter.importFile(at: target) + let linkImport = try await ComposerAttachmentImporter.importFile(at: link) + + #expect(targetImport.deduplicationIdentity == linkImport.deduplicationIdentity) + } + } + + @Test func clipboardImagesAreTemporaryAndCleanupNeverDeletesExternalFiles() async throws { + let externalDirectory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: externalDirectory) } + let externalURL = externalDirectory.appendingPathComponent("external.png") + try pngData(width: 2, height: 2).write(to: externalURL) + + let pngAttachment = try await ComposerAttachmentImporter.importClipboardImage( + pngData: pngData(width: 4, height: 3), tiffData: nil) + let tiffAttachment = try await ComposerAttachmentImporter.importClipboardImage( + pngData: nil, tiffData: tiffData(width: 4, height: 3)) + + #expect(pngAttachment.storageOwnership == .appOwnedTemporaryFile) + #expect(tiffAttachment.storageOwnership == .appOwnedTemporaryFile) + #expect(FileManager.default.fileExists(atPath: pngAttachment.sourceURL.path)) + #expect(FileManager.default.fileExists(atPath: tiffAttachment.sourceURL.path)) + + try ComposerAttachmentImporter.cleanupTemporaryAttachment(pngAttachment) + try ComposerAttachmentImporter.cleanupTemporaryAttachment(tiffAttachment) + #expect(!FileManager.default.fileExists(atPath: pngAttachment.sourceURL.path)) + + let externalAttachment = try await ComposerAttachmentImporter.importFile(at: externalURL) + #expect(throws: ComposerAttachmentImporter.ImportError.self) { + try ComposerAttachmentImporter.cleanupTemporaryAttachment(externalAttachment) + } + #expect(FileManager.default.fileExists(atPath: externalURL.path)) + } + + private func withTemporaryDirectory(_ body: (URL) async throws -> Void) async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try await body(directory) + } + + private func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func pngData(width: Int, height: Int) throws -> Data { + let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, bitmapFormat: [], bytesPerRow: 0, bitsPerPixel: 0) + guard let rep, let data = rep.representation(using: .png, properties: [:]) else { + throw CocoaError(.fileWriteUnknown) + } + return data + } + + private func tiffData(width: Int, height: Int) throws -> Data { + let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, bitmapFormat: [], bytesPerRow: 0, bitsPerPixel: 0) + guard let rep, let data = rep.tiffRepresentation else { throw CocoaError(.fileWriteUnknown) } + return data + } +} From cc30eec8537145bfb8104d95d0c7c370b4953baf Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 2 Oct 2026 09:11:02 +0200 Subject: [PATCH 2/9] Prepare ChatComposerState for attachments --- .../ChatInputView/ChatComposerState.swift | 85 +++++++++++- .../ChatComposerStateTests.swift | 127 ++++++++++++++++++ 2 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 Tests/MactrixAppTests/ChatComposerStateTests.swift diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatComposerState.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatComposerState.swift index fe52a2c..a37bf5e 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatComposerState.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatComposerState.swift @@ -1,16 +1,29 @@ +import Foundation import MatrixRustSDK -import Observation + +@MainActor +struct ComposerSubmissionSnapshot { + let text: String + let replyTarget: EventTimelineItem? + let replyEventIdentifier: String? + let attachments: [ComposerAttachment] +} @MainActor @Observable final class ChatComposerState { var text = "" private(set) var replyTarget: EventTimelineItem? + private(set) var attachments: [ComposerAttachment] = [] private(set) var isDraftLoaded = false private(set) var focusRequest = 0 + var canSend: Bool { + !text.isEmpty || !attachments.isEmpty + } + func beginReply(to event: EventTimelineItem) { replyTarget = event - focusRequest += 1 + requestTextFocus() } func restoreReply(to event: EventTimelineItem) { @@ -18,7 +31,67 @@ final class ChatComposerState { } func cancelReply() { + guard replyTarget != nil else { return } + replyTarget = nil + } + + func addAttachments(_ newAttachments: [ComposerAttachment]) { + var identities = Set(attachments.map(\.deduplicationIdentity)) + let uniqueAttachments = newAttachments.filter { + identities.insert($0.deduplicationIdentity).inserted + } + guard !uniqueAttachments.isEmpty else { return } + attachments.append(contentsOf: uniqueAttachments) + } + + func removeAttachment(id: ComposerAttachment.ID) { + guard let index = attachments.firstIndex(where: { $0.id == id }) else { return } + let attachment = attachments.remove(at: index) + cleanupIfOwned(attachment) + } + + func requestTextFocus() { + focusRequest += 1 + } + + func captureSubmission() -> ComposerSubmissionSnapshot { + ComposerSubmissionSnapshot( + text: text, + replyTarget: replyTarget, + replyEventIdentifier: replyTarget?.eventOrTransactionId.id, + attachments: attachments, + ) + } + + /// Clears only content included in `snapshot`, preserving edits made while submission was prepared. + /// Set `cleanupAttachments` to false when the sending layer needs to retain temporary sources. + func finishSending( + snapshot: ComposerSubmissionSnapshot, cleanupAttachments: Bool = true + ) { + if text == snapshot.text { + text = "" + } + if replyTarget?.eventOrTransactionId.id == snapshot.replyEventIdentifier { + replyTarget = nil + } + + let capturedIDs = Set(snapshot.attachments.map(\.id)) + let submittedAttachments = attachments.filter { capturedIDs.contains($0.id) } + guard !submittedAttachments.isEmpty else { return } + attachments.removeAll { capturedIDs.contains($0.id) } + + if cleanupAttachments { + submittedAttachments.forEach(cleanupIfOwned) + } + } + + /// Clears the entire composer, including app-owned temporary attachment files. + func reset() { + let removedAttachments = attachments + text = "" replyTarget = nil + attachments = [] + removedAttachments.forEach(cleanupIfOwned) } func completeDraftRestoration() { @@ -26,7 +99,11 @@ final class ChatComposerState { } func finishSending() { - text = "" - replyTarget = nil + finishSending(snapshot: captureSubmission()) + } + + private func cleanupIfOwned(_ attachment: ComposerAttachment) { + guard attachment.storageOwnership == .appOwnedTemporaryFile else { return } + try? ComposerAttachmentImporter.cleanupTemporaryAttachment(attachment) } } diff --git a/Tests/MactrixAppTests/ChatComposerStateTests.swift b/Tests/MactrixAppTests/ChatComposerStateTests.swift new file mode 100644 index 0000000..5c49306 --- /dev/null +++ b/Tests/MactrixAppTests/ChatComposerStateTests.swift @@ -0,0 +1,127 @@ +import AppKit +import Foundation +import Testing +import UniformTypeIdentifiers + +@testable import MactrixApp + +@MainActor +struct ChatComposerStateTests { + @Test func attachmentsAppendInOrderAndDeduplicate() { + let composer = ChatComposerState() + let first = attachment(filename: "first.txt", identity: .absolutePath("/one/first.txt")) + let duplicate = attachment(filename: "copy.txt", identity: .absolutePath("/one/first.txt")) + let second = attachment(filename: "second.txt", identity: .absolutePath("/two/second.txt")) + + composer.addAttachments([first, duplicate, second]) + composer.addAttachments([first]) + + #expect(composer.attachments.map(\.id) == [first.id, second.id]) + } + + @Test func differentFilesWithSameFilenameRemain() { + let composer = ChatComposerState() + let first = attachment(filename: "report.txt", identity: .absolutePath("/one/report.txt")) + let second = attachment(filename: "report.txt", identity: .absolutePath("/two/report.txt")) + + composer.addAttachments([first, second]) + + #expect(composer.attachments.map(\.id) == [first.id, second.id]) + } + + @Test func removingAttachmentPreservesOtherComposerState() { + let composer = ChatComposerState() + let first = attachment(filename: "first.txt", identity: .absolutePath("/first.txt")) + let second = attachment(filename: "second.txt", identity: .absolutePath("/second.txt")) + composer.text = "caption" + composer.addAttachments([first, second]) + + composer.removeAttachment(id: first.id) + + #expect(composer.text == "caption") + #expect(composer.attachments.map(\.id) == [second.id]) + } + + @Test func removalCleansOnlyAppOwnedTemporaryFiles() throws { + let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent( + "MactrixComposerAttachments/state-test-\(UUID().uuidString).txt") + try FileManager.default.createDirectory( + at: temporaryURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("temporary".utf8).write(to: temporaryURL) + defer { try? FileManager.default.removeItem(at: temporaryURL) } + + let externalURL = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) + try Data("external".utf8).write(to: externalURL) + defer { try? FileManager.default.removeItem(at: externalURL) } + + let owned = attachment( + url: temporaryURL, filename: "temporary.txt", ownership: .appOwnedTemporaryFile, + identity: .absolutePath(temporaryURL.path)) + let external = attachment( + url: externalURL, filename: "external.txt", ownership: .externalUserFile, + identity: .absolutePath(externalURL.path)) + let composer = ChatComposerState() + composer.addAttachments([owned, external]) + + composer.removeAttachment(id: owned.id) + composer.removeAttachment(id: external.id) + + #expect(!FileManager.default.fileExists(atPath: temporaryURL.path)) + #expect(FileManager.default.fileExists(atPath: externalURL.path)) + } + + @Test func canSendReflectsTextAndAttachments() { + let composer = ChatComposerState() + #expect(!composer.canSend) + + composer.text = "text" + #expect(composer.canSend) + + composer.text = "" + composer.addAttachments([ + attachment(filename: "file.txt", identity: .absolutePath("/file.txt")) + ]) + #expect(composer.canSend) + } + + @Test func resetClearsTextAndAttachments() { + let composer = ChatComposerState() + composer.text = "caption" + composer.addAttachments([attachment(filename: "file.txt", identity: .absolutePath("/file.txt"))] + ) + + composer.reset() + + #expect(composer.text.isEmpty) + #expect(composer.replyTarget == nil) + #expect(composer.attachments.isEmpty) + } + + @Test func snapshotSuccessClearsCapturedContentButRetainsLaterChanges() { + let composer = ChatComposerState() + let captured = attachment(filename: "captured.txt", identity: .absolutePath("/captured.txt")) + let later = attachment(filename: "later.txt", identity: .absolutePath("/later.txt")) + composer.text = "original" + composer.addAttachments([captured]) + let snapshot = composer.captureSubmission() + + composer.text = "updated" + composer.addAttachments([later]) + composer.finishSending(snapshot: snapshot) + + #expect(composer.text == "updated") + #expect(composer.attachments.map(\.id) == [later.id]) + } + + private func attachment( + url: URL = URL(fileURLWithPath: "/tmp/attachment"), + filename: String, + ownership: ComposerAttachment.StorageOwnership = .externalUserFile, + identity: ComposerAttachment.DeduplicationIdentity + ) -> ComposerAttachment { + ComposerAttachment( + sourceURL: url, filename: filename, contentType: .plainText, byteCount: nil, kind: .file, + storageOwnership: ownership, deduplicationIdentity: identity) + } +} From 4f6c87430ad76ca33443b49a5b059dcdb688f9fd Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 2 Oct 2026 09:52:48 +0200 Subject: [PATCH 3/9] Add attachment previews --- .../ChatInputView/ChatInputView.swift | 9 +- .../ComposerAttachmentPreviewTile.swift | 175 ++++++++++++++++++ .../MessageImageContentView.swift | 23 --- .../Views/MediaQuickLookPreview.swift | 30 +++ .../ComposerAttachmentPreviewTileTests.swift | 36 ++++ 5 files changed, 249 insertions(+), 24 deletions(-) create mode 100644 Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentPreviewTile.swift create mode 100644 Sources/MactrixApp/Views/MediaQuickLookPreview.swift create mode 100644 Tests/MactrixAppTests/ComposerAttachmentPreviewTileTests.swift diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift index 31683f4..15d3b68 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift @@ -121,8 +121,15 @@ struct ChatInputView: View { if let replyEmbeddedDetails { EmbeddedMessageView(embeddedEvent: replyEmbeddedDetails) { composer.cancelReply() - } + }.padding([.horizontal, .top], 10) + } + + ComposerAttachmentPreviewStrip(attachments: composer.attachments) { attachmentID in + composer.removeAttachment(id: attachmentID) } + .padding(.horizontal, 10) + .padding(.top, replyEmbeddedDetails == nil ? 10 : 0) + ChatTextView( text: $composer.text, placeholder: "Message \(room.displayName() ?? "room")", diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentPreviewTile.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentPreviewTile.swift new file mode 100644 index 0000000..b7f57cd --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentPreviewTile.swift @@ -0,0 +1,175 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +struct ComposerAttachmentPreviewTile: View { + static let sideLength: CGFloat = 72 + + enum Visual { + case thumbnail(NSImage) + case fileIcon(NSImage) + } + + let attachment: ComposerAttachment + let onRemove: () -> Void + + static func usesThumbnail(for attachment: ComposerAttachment) -> Bool { + attachment.preview != nil && (attachment.kind == .image || attachment.kind == .video) + } + + static func visual(for attachment: ComposerAttachment) -> Visual { + if usesThumbnail(for: attachment), let preview = attachment.preview { + return .thumbnail(preview) + } + return .fileIcon(NSWorkspace.shared.icon(for: attachment.contentType)) + } + + private var typeDescription: String { + switch attachment.kind { + case .image: "image" + case .video: "video" + case .audio: "audio" + case .file: "file" + } + } + + private var tooltip: String { + if let byteCount = attachment.byteCount { + return "\(attachment.filename) (\(byteCount.formatted(.byteCount(style: .file))))" + } else { + return attachment.filename + } + } + + var body: some View { + VStack(spacing: 0) { + ZStack(alignment: .topTrailing) { + Button { + MediaQuickLookPreview.shared.show(url: attachment.sourceURL) + } label: { + ZStack { + switch Self.visual(for: attachment) { + case .thumbnail(let image): + Image(nsImage: image) + .resizable() + .scaledToFill() + .frame(width: Self.sideLength, height: Self.sideLength) + case .fileIcon(let image): + Image(nsImage: image) + .resizable() + .scaledToFit() + .frame(width: Self.sideLength, height: Self.sideLength) + } + + if attachment.kind == .video { + Image(systemName: "play.circle.fill") + .font(.largeTitle) + .foregroundStyle(.white, .black.opacity(0.45)) + .accessibilityHidden(true) + } + } + .frame(width: Self.sideLength, height: Self.sideLength) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .help(tooltip) + .accessibilityLabel("\(attachment.filename), \(typeDescription), \(tooltip)") + .accessibilityHint("Open in Quick Look") + + Button(action: onRemove) { + Image(systemName: "xmark") + .font(.caption.bold()) + .foregroundStyle(.primary) + .frame(width: 20, height: 20) + .background(.regularMaterial, in: Circle()) + } + .buttonStyle(.plain) + .padding(3) + .help("Remove \(attachment.filename)") + } + .frame(width: Self.sideLength, height: Self.sideLength) + + Text(attachment.filename) + .font(.caption2) + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 5) + .padding(.vertical, 4) + .frame(width: Self.sideLength) + } + } +} + +struct ComposerAttachmentPreviewStrip: View { + let attachments: [ComposerAttachment] + let onRemove: (ComposerAttachment.ID) -> Void + + var body: some View { + if !attachments.isEmpty { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(attachments) { attachment in + ComposerAttachmentPreviewTile(attachment: attachment) { + onRemove(attachment.id) + } + } + } + .padding(.horizontal, 2) + } + .scrollIndicators(.hidden) + .frame(height: ComposerAttachmentPreviewTile.sideLength) + .accessibilityElement(children: .contain) + .accessibilityLabel("Attachments") + } + } +} + +#if DEBUG + private enum ComposerAttachmentPreviewFixtures { + static let thumbnail = NSImage(size: CGSize(width: 320, height: 180), flipped: false) { rect in + NSColor.systemIndigo.setFill() + rect.fill() + NSColor.white.withAlphaComponent(0.2).setFill() + NSBezierPath(ovalIn: rect.insetBy(dx: 45, dy: 20)).fill() + return true + } + + static let attachments: [ComposerAttachment] = [ + attachment( + filename: "sunset-over-the-mountains.png", type: .png, kind: .image, preview: thumbnail), + attachment( + filename: "conference-recording.mov", type: .quickTimeMovie, kind: .video, + preview: thumbnail), + attachment(filename: "weekly-standup.m4a", type: .mpeg4Audio, kind: .audio), + attachment( + filename: "very-long-project-specification-for-accessibility-testing.pdf", type: .pdf, + kind: .file), + ] + + private static func attachment( + filename: String, + type: UTType, + kind: ComposerAttachment.Kind, + preview: NSImage? = nil + ) -> ComposerAttachment { + ComposerAttachment( + sourceURL: URL(fileURLWithPath: "/tmp/\(filename)"), + filename: filename, + contentType: type, + byteCount: 1_536_000, + kind: kind, + storageOwnership: .externalUserFile, + deduplicationIdentity: .absolutePath("/tmp/\(filename)"), + preview: preview) + } + } + + #Preview("Attachment preview tiles") { + ComposerAttachmentPreviewStrip(attachments: ComposerAttachmentPreviewFixtures.attachments) { + _ in + } + .padding() + .frame(width: 440) + } +#endif diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift index 6f40e40..77bec76 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift @@ -2,7 +2,6 @@ import AppKit import MatrixIntegration import MatrixRustSDK import OSLog -import QuickLookUI /// One image and its optional text caption. Both views survive table row reuse. final class MessageImageContentView: NSView, MessageContentRowView { @@ -227,25 +226,3 @@ final class MessageImageContentView: NSView, MessageContentRowView { fatalError("init(coder:) has not been implemented") } } - -/// Keeps the temporary media file alive while Quick Look is displaying it. -@MainActor final class MediaQuickLookPreview: NSObject, @preconcurrency QLPreviewPanelDataSource { - static let shared = MediaQuickLookPreview() - private var handle: MediaFileHandle? - private var url: URL? - - func show(handle: MediaFileHandle, url: URL) { - self.handle = handle - self.url = url - guard let panel = QLPreviewPanel.shared() else { return } - panel.dataSource = self - panel.reloadData() - panel.makeKeyAndOrderFront(nil) - } - - func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { url == nil ? 0 : 1 } - - func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! { - url as NSURL? - } -} diff --git a/Sources/MactrixApp/Views/MediaQuickLookPreview.swift b/Sources/MactrixApp/Views/MediaQuickLookPreview.swift new file mode 100644 index 0000000..d8756e3 --- /dev/null +++ b/Sources/MactrixApp/Views/MediaQuickLookPreview.swift @@ -0,0 +1,30 @@ +import AppKit +import MatrixRustSDK +import QuickLookUI + +/// Presents a local file in Quick Look and retains an optional owner for temporary SDK media. +@MainActor final class MediaQuickLookPreview: NSObject, @preconcurrency QLPreviewPanelDataSource { + static let shared = MediaQuickLookPreview() + + private var retainedFileOwner: AnyObject? + private var url: URL? + + func show(url: URL, retaining fileOwner: AnyObject? = nil) { + retainedFileOwner = fileOwner + self.url = url + guard let panel = QLPreviewPanel.shared() else { return } + panel.dataSource = self + panel.reloadData() + panel.makeKeyAndOrderFront(nil) + } + + func show(handle: MediaFileHandle, url: URL) { + show(url: url, retaining: handle) + } + + func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { url == nil ? 0 : 1 } + + func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! { + url as NSURL? + } +} diff --git a/Tests/MactrixAppTests/ComposerAttachmentPreviewTileTests.swift b/Tests/MactrixAppTests/ComposerAttachmentPreviewTileTests.swift new file mode 100644 index 0000000..42669dc --- /dev/null +++ b/Tests/MactrixAppTests/ComposerAttachmentPreviewTileTests.swift @@ -0,0 +1,36 @@ +import AppKit +import Testing +import UniformTypeIdentifiers + +@testable import MactrixApp + +@MainActor +struct ComposerAttachmentPreviewTileTests { + @Test func imageAndVideoUseAvailableThumbnails() { + #expect( + ComposerAttachmentPreviewTile.usesThumbnail(for: attachment(kind: .image, preview: true))) + #expect( + ComposerAttachmentPreviewTile.usesThumbnail(for: attachment(kind: .video, preview: true))) + } + + @Test func audioAndFilesUseTypeIconsEvenWhenAnImageIsPresent() { + #expect( + !ComposerAttachmentPreviewTile.usesThumbnail(for: attachment(kind: .audio, preview: true))) + #expect( + !ComposerAttachmentPreviewTile.usesThumbnail(for: attachment(kind: .file, preview: true))) + #expect( + !ComposerAttachmentPreviewTile.usesThumbnail(for: attachment(kind: .image, preview: false))) + } + + private func attachment(kind: ComposerAttachment.Kind, preview: Bool) -> ComposerAttachment { + ComposerAttachment( + sourceURL: URL(fileURLWithPath: "/tmp/attachment"), + filename: "filename.pdf", + contentType: .pdf, + byteCount: 1, + kind: kind, + storageOwnership: .externalUserFile, + deduplicationIdentity: .absolutePath("/tmp/attachment"), + preview: preview ? NSImage(size: CGSize(width: 1, height: 1)) : nil) + } +} From 3a2b6d1c3def4779c4ad4486d45e2fca1ed96640 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 2 Oct 2026 10:46:00 +0200 Subject: [PATCH 4/9] Add drag/drop and paste --- .../ChatInputView/ChatInputView.swift | 78 +++++++++++++++++-- .../ChatView/ChatInputView/ChatTextView.swift | 10 +++ .../ComposerAttachmentPreviewTile.swift | 23 +++--- .../ComposerAttachmentPreviewTileTests.swift | 15 ++-- 4 files changed, 99 insertions(+), 27 deletions(-) diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift index 15d3b68..9b1a895 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift @@ -1,3 +1,4 @@ +import AppKit import MatrixIntegration import MatrixRustSDK import OSLog @@ -10,6 +11,42 @@ struct ChatInputView: View { let timeline: LiveTimeline @Bindable var composer: ChatComposerState @AppStorage("fontSize") var fontSize: Int = 13 + @State private var isAttachmentDropTargeted = false + + private func ingestFileURLs(_ urls: [URL]) async { + let attachments = await ComposerAttachmentImporter.importFiles(at: urls) + guard !attachments.isEmpty else { return } + composer.addAttachments(attachments) + composer.requestTextFocus() + } + + private func handlePaste(from pasteboard: NSPasteboard) -> Bool { + let fileURLs = + pasteboard.readObjects( + forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true] + ) as? [URL] ?? [] + if !fileURLs.isEmpty { + Task { await ingestFileURLs(fileURLs) } + return true + } + + guard let imageType = pasteboard.availableType(from: [.png, .tiff]), + let imageData = pasteboard.data(forType: imageType) + else { return false } + + Task { + do { + let attachment = try await ComposerAttachmentImporter.importClipboardImage( + pngData: imageType == .png ? imageData : nil, + tiffData: imageType == .tiff ? imageData : nil) + composer.addAttachments([attachment]) + composer.requestTextFocus() + } catch { + Logger.composerAttachment.error("Unable to import clipboard image: \(error)") + } + } + return true + } func sendMessage() async { guard !composer.text.isEmpty else { return } @@ -121,13 +158,13 @@ struct ChatInputView: View { if let replyEmbeddedDetails { EmbeddedMessageView(embeddedEvent: replyEmbeddedDetails) { composer.cancelReply() - }.padding([.horizontal, .top], 10) + } + .padding([.horizontal, .top], 10) } ComposerAttachmentPreviewStrip(attachments: composer.attachments) { attachmentID in composer.removeAttachment(id: attachmentID) } - .padding(.horizontal, 10) .padding(.top, replyEmbeddedDetails == nil ? 10 : 0) ChatTextView( @@ -135,7 +172,8 @@ struct ChatInputView: View { placeholder: "Message \(room.displayName() ?? "room")", disabled: !composer.isDraftLoaded, focusRequest: composer.focusRequest, - onSubmit: { Task { await sendMessage() } } + onSubmit: { Task { await sendMessage() } }, + onAttachmentPaste: handlePaste ) } .font(.system(size: .init(fontSize))) @@ -156,6 +194,7 @@ struct ChatInputView: View { var tahoeView: some View { content .glassEffect(in: .rect(cornerRadius: 16.0)) + .background(dropHighlight) .padding(.horizontal) .padding(.bottom, 10) } @@ -166,17 +205,44 @@ struct ChatInputView: View { .cornerRadius(4) .overlay( RoundedRectangle(cornerRadius: 16.0) - .stroke(Color(NSColor.separatorColor), lineWidth: 1) + .stroke( + isAttachmentDropTargeted ? Color.accentColor : Color(NSColor.separatorColor), + lineWidth: isAttachmentDropTargeted ? 2 : 1) ) + .background(dropHighlight) .padding(.horizontal) .padding(.bottom, 10) } + private var dropHighlight: some View { + RoundedRectangle(cornerRadius: 16.0) + .fill(Color.accentColor.opacity(isAttachmentDropTargeted ? 0.10 : 0)) + .overlay( + RoundedRectangle(cornerRadius: 16.0) + .stroke(Color.accentColor.opacity(isAttachmentDropTargeted ? 0.7 : 0), lineWidth: 2) + ) + .allowsHitTesting(false) + } + + private func dropTarget(for view: Content) -> some View { + view + .contentShape(Rectangle()) + .dropDestination(for: URL.self) { urls, _ in + let fileURLs = urls.filter(\.isFileURL) + guard !fileURLs.isEmpty else { return false } + + Task { await ingestFileURLs(fileURLs) } + return true + } isTargeted: { isTargeted in + isAttachmentDropTargeted = isTargeted + } + } + var body: some View { if #available(macOS 26.0, *), !reduceTransparency { - tahoeView + dropTarget(for: tahoeView) } else { - oldView + dropTarget(for: oldView) } } } diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift index e666189..d723c6d 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift @@ -12,11 +12,13 @@ struct ChatTextView: NSViewRepresentable { let disabled: Bool let focusRequest: Int let onSubmit: () -> Void + let onAttachmentPaste: (NSPasteboard) -> Bool func makeNSView(context: Context) -> DynamicTextView { let textView = DynamicTextView() textView.onSubmit = onSubmit + textView.onAttachmentPaste = onAttachmentPaste textView.placeholderAttributedString = NSAttributedString( string: placeholder, @@ -50,6 +52,7 @@ struct ChatTextView: NSViewRepresentable { context.coordinator.text = text textView.onSubmit = onSubmit + textView.onAttachmentPaste = onAttachmentPaste if textView.string != text.wrappedValue { textView.string = text.wrappedValue @@ -140,6 +143,13 @@ class DynamicTextView: NSTextView { static let padding = NSSize(width: 10, height: 10) var onSubmit: (() -> Void)? + var onAttachmentPaste: ((NSPasteboard) -> Bool)? + + override func paste(_ sender: Any?) { + if onAttachmentPaste?(NSPasteboard.general) != true { + super.paste(sender) + } + } override var intrinsicContentSize: NSSize { guard let container = unsafe textContainer, let manager = unsafe layoutManager else { diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentPreviewTile.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentPreviewTile.swift index b7f57cd..850ac29 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentPreviewTile.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentPreviewTile.swift @@ -2,6 +2,12 @@ import AppKit import SwiftUI import UniformTypeIdentifiers +extension ComposerAttachment { + var usesThumbnail: Bool { + self.preview != nil && (self.kind == .image || self.kind == .video) + } +} + struct ComposerAttachmentPreviewTile: View { static let sideLength: CGFloat = 72 @@ -13,12 +19,8 @@ struct ComposerAttachmentPreviewTile: View { let attachment: ComposerAttachment let onRemove: () -> Void - static func usesThumbnail(for attachment: ComposerAttachment) -> Bool { - attachment.preview != nil && (attachment.kind == .image || attachment.kind == .video) - } - - static func visual(for attachment: ComposerAttachment) -> Visual { - if usesThumbnail(for: attachment), let preview = attachment.preview { + func visual() -> Visual { + if attachment.usesThumbnail, let preview = attachment.preview { return .thumbnail(preview) } return .fileIcon(NSWorkspace.shared.icon(for: attachment.contentType)) @@ -48,7 +50,7 @@ struct ComposerAttachmentPreviewTile: View { MediaQuickLookPreview.shared.show(url: attachment.sourceURL) } label: { ZStack { - switch Self.visual(for: attachment) { + switch visual() { case .thumbnail(let image): Image(nsImage: image) .resizable() @@ -69,7 +71,7 @@ struct ComposerAttachmentPreviewTile: View { } } .frame(width: Self.sideLength, height: Self.sideLength) - .background(Color(nsColor: .controlBackgroundColor)) + // .background(Color(nsColor: .controlBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) @@ -95,7 +97,7 @@ struct ComposerAttachmentPreviewTile: View { .lineLimit(1) .truncationMode(.middle) .padding(.horizontal, 5) - .padding(.vertical, 4) + .padding(.top, 4) .frame(width: Self.sideLength) } } @@ -115,10 +117,9 @@ struct ComposerAttachmentPreviewStrip: View { } } } - .padding(.horizontal, 2) + .padding(.horizontal, 10) } .scrollIndicators(.hidden) - .frame(height: ComposerAttachmentPreviewTile.sideLength) .accessibilityElement(children: .contain) .accessibilityLabel("Attachments") } diff --git a/Tests/MactrixAppTests/ComposerAttachmentPreviewTileTests.swift b/Tests/MactrixAppTests/ComposerAttachmentPreviewTileTests.swift index 42669dc..e777f37 100644 --- a/Tests/MactrixAppTests/ComposerAttachmentPreviewTileTests.swift +++ b/Tests/MactrixAppTests/ComposerAttachmentPreviewTileTests.swift @@ -7,19 +7,14 @@ import UniformTypeIdentifiers @MainActor struct ComposerAttachmentPreviewTileTests { @Test func imageAndVideoUseAvailableThumbnails() { - #expect( - ComposerAttachmentPreviewTile.usesThumbnail(for: attachment(kind: .image, preview: true))) - #expect( - ComposerAttachmentPreviewTile.usesThumbnail(for: attachment(kind: .video, preview: true))) + #expect(attachment(kind: .image, preview: true).usesThumbnail) + #expect(attachment(kind: .video, preview: true).usesThumbnail) } @Test func audioAndFilesUseTypeIconsEvenWhenAnImageIsPresent() { - #expect( - !ComposerAttachmentPreviewTile.usesThumbnail(for: attachment(kind: .audio, preview: true))) - #expect( - !ComposerAttachmentPreviewTile.usesThumbnail(for: attachment(kind: .file, preview: true))) - #expect( - !ComposerAttachmentPreviewTile.usesThumbnail(for: attachment(kind: .image, preview: false))) + #expect(!attachment(kind: .audio, preview: true).usesThumbnail) + #expect(!attachment(kind: .file, preview: true).usesThumbnail) + #expect(!attachment(kind: .image, preview: false).usesThumbnail) } private func attachment(kind: ComposerAttachment.Kind, preview: Bool) -> ComposerAttachment { From abe1f8925f0b1398963367e37ce1b1834a8f6381 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 2 Oct 2026 16:09:18 +0200 Subject: [PATCH 5/9] Add draft restoration --- .../ChatInputView/ChatComposerState.swift | 27 ++- .../ChatInputView/ChatInputView.swift | 47 +++-- .../ChatInputView/ComposerAttachment.swift | 55 ++++++ .../ComposerAttachmentImporter.swift | 78 ++++++++- .../ChatComposerStateTests.swift | 12 ++ .../ComposerAttachmentImporterTests.swift | 160 +++++++++++++++++- 6 files changed, 351 insertions(+), 28 deletions(-) diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatComposerState.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatComposerState.swift index a37bf5e..000057d 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatComposerState.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatComposerState.swift @@ -11,10 +11,14 @@ struct ComposerSubmissionSnapshot { @MainActor @Observable final class ChatComposerState { - var text = "" + var text = "" { + didSet { draftRevision += 1 } + } private(set) var replyTarget: EventTimelineItem? private(set) var attachments: [ComposerAttachment] = [] private(set) var isDraftLoaded = false + private var isDraftRestoring = false + private(set) var draftRevision = 0 private(set) var focusRequest = 0 var canSend: Bool { @@ -23,16 +27,19 @@ final class ChatComposerState { func beginReply(to event: EventTimelineItem) { replyTarget = event + draftRevision += 1 requestTextFocus() } func restoreReply(to event: EventTimelineItem) { replyTarget = event + draftRevision += 1 } func cancelReply() { guard replyTarget != nil else { return } replyTarget = nil + draftRevision += 1 } func addAttachments(_ newAttachments: [ComposerAttachment]) { @@ -42,11 +49,13 @@ final class ChatComposerState { } guard !uniqueAttachments.isEmpty else { return } attachments.append(contentsOf: uniqueAttachments) + draftRevision += 1 } func removeAttachment(id: ComposerAttachment.ID) { guard let index = attachments.firstIndex(where: { $0.id == id }) else { return } let attachment = attachments.remove(at: index) + draftRevision += 1 cleanupIfOwned(attachment) } @@ -73,12 +82,14 @@ final class ChatComposerState { } if replyTarget?.eventOrTransactionId.id == snapshot.replyEventIdentifier { replyTarget = nil + draftRevision += 1 } let capturedIDs = Set(snapshot.attachments.map(\.id)) let submittedAttachments = attachments.filter { capturedIDs.contains($0.id) } guard !submittedAttachments.isEmpty else { return } attachments.removeAll { capturedIDs.contains($0.id) } + draftRevision += 1 if cleanupAttachments { submittedAttachments.forEach(cleanupIfOwned) @@ -91,10 +102,24 @@ final class ChatComposerState { text = "" replyTarget = nil attachments = [] + draftRevision += 1 removedAttachments.forEach(cleanupIfOwned) } + /// Starts a single draft restoration. Returns false while another restoration is in progress. + func beginDraftRestoration() -> Bool { + guard !isDraftLoaded, !isDraftRestoring else { return false } + isDraftRestoring = true + return true + } + + /// Allows restoration to retry when a dependency, such as the timeline, is unavailable. + func deferDraftRestoration() { + isDraftRestoring = false + } + func completeDraftRestoration() { + isDraftRestoring = false isDraftLoaded = true } diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift index 9b1a895..cda2d4a 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift @@ -71,7 +71,7 @@ struct ChatInputView: View { private func saveDraft() async { guard composer.isDraftLoaded else { return } // avoid overwriting a draft before restoration - if composer.text.isEmpty, composer.replyTarget == nil { + if composer.text.isEmpty, composer.replyTarget == nil, composer.attachments.isEmpty { Logger.viewCycle.debug("clearing draft") do { try await room.clearComposerDraft(threadRoot: timeline.focusedThreadId) @@ -91,7 +91,7 @@ struct ChatInputView: View { plainText: composer.text, htmlText: nil, draftType: draftType, - attachments: [] + attachments: composer.attachments.map(\.draftAttachment) ) do { try await room.saveComposerDraft(draft: draft, threadRoot: timeline.focusedThreadId) @@ -101,20 +101,21 @@ struct ChatInputView: View { } private func loadDraft() async { - guard !composer.isDraftLoaded else { return } // don't load a draft more than once + guard composer.beginDraftRestoration() else { return } do { guard let draft = try await room.loadComposerDraft(threadRoot: timeline.focusedThreadId) else { - // no draft to load composer.completeDraftRestoration() return } composer.text = draft.plainText switch draft.draftType { case .reply(let eventId): - // we need a timeline to be able to populate the reply; return false so we can try again - guard let innerTimeline = timeline.timeline else { return } - + // We need a timeline to resolve a reply target, so retry when it becomes available. + guard let innerTimeline = timeline.timeline else { + composer.deferDraftRestoration() + return + } do { let item = try await innerTimeline.getEventTimelineItemByEventId(eventId: eventId) composer.restoreReply(to: item) @@ -122,17 +123,39 @@ struct ChatInputView: View { Logger.viewCycle.error("failed to resolve reply target: \(error)") } case .newMessage, .edit: - // nothing to do break } + + var discardedAttachment = false + for draftAttachment in draft.attachments { + let attachmentCount = composer.attachments.count + do { + let attachment = try await ComposerAttachment.restoreDraftAttachment(draftAttachment) + composer.addAttachments([attachment]) + if composer.attachments.count == attachmentCount { + discardedAttachment = true // duplicate entries are saved only once + if attachment.storageOwnership == .appOwnedTemporaryFile { + try? ComposerAttachmentImporter.cleanupTemporaryAttachment(attachment) + } + } + } catch { + discardedAttachment = true + Logger.composerAttachment.warning("Discarding unavailable draft attachment: \(error)") + } + } + composer.completeDraftRestoration() + if discardedAttachment { + await saveDraft() + } } catch { Logger.viewCycle.error("failed to load draft: \(error)") + composer.completeDraftRestoration() } - composer.completeDraftRestoration() } private func chatInputChanged() async { guard composer.isDraftLoaded else { return } // avoid working on a draft being restored + await saveDraft() if !composer.text.isEmpty { do { try await room.typingNotice(isTyping: !composer.text.isEmpty) @@ -140,7 +163,6 @@ struct ChatInputView: View { Logger.viewCycle.warning("Failed to send typing notice: \(error)") } } - await saveDraft() } var replyEmbeddedDetails: EmbeddedEventDetails? { @@ -177,12 +199,9 @@ struct ChatInputView: View { ) } .font(.system(size: .init(fontSize))) - .task(id: composer.text) { + .task(id: composer.draftRevision) { await chatInputChanged() } - .task(id: composer.replyTarget?.eventOrTransactionId) { - await saveDraft() - } .task(id: timeline.timeline != nil) { // we need the timeline to be populated before we load a draft // (in case the draft holds a reply) diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachment.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachment.swift index 2b8ffb6..78c4d1d 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachment.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachment.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import MatrixRustSDK import UniformTypeIdentifiers struct ComposerAttachment: Identifiable { @@ -67,3 +68,57 @@ struct ComposerAttachment: Identifiable { self.preview = preview } } + +extension ComposerAttachment { + + var draftAttachment: DraftAttachment { + // The SDK reads this file into its local draft store and returns .data when loading. + let source = UploadSource.file(filename: sourceURL.path) + let size = byteCount.flatMap { $0 >= 0 ? UInt64($0) : nil } + + switch kind { + case .audio: + return .audio( + audioInfo: AudioInfo(duration: duration, size: size, mimetype: mimeType), source: source) + case .file: + return .file( + fileInfo: FileInfo( + mimetype: mimeType, size: size, thumbnailInfo: nil, thumbnailSource: nil), source: source) + case .image: + return .image( + imageInfo: ImageInfo( + height: pixelHeight.map(UInt64.init), width: pixelWidth.map(UInt64.init), + mimetype: mimeType, + size: size, thumbnailInfo: nil, thumbnailSource: nil, blurhash: nil, isAnimated: nil), + source: source, thumbnailSource: nil) + case .video: + return .video( + videoInfo: VideoInfo( + duration: duration, height: pixelHeight.map(UInt64.init), + width: pixelWidth.map(UInt64.init), + mimetype: mimeType, size: size, thumbnailInfo: nil, thumbnailSource: nil, blurhash: nil), + source: source, thumbnailSource: nil) + } + } + + static func restoreDraftAttachment(_ draftAttachment: DraftAttachment) async throws + -> ComposerAttachment + { + let source: UploadSource + switch draftAttachment { + case .audio(_, let attachmentSource), .file(_, let attachmentSource), + .image(_, let attachmentSource, _), .video(_, let attachmentSource, _): + source = attachmentSource + } + + switch source { + case .file(let filename): + guard !filename.isEmpty, (filename as NSString).isAbsolutePath else { + throw ComposerAttachmentImporter.ImportError.invalidDraftSource(filename) + } + return try await ComposerAttachmentImporter.importFile(at: URL(fileURLWithPath: filename)) + case .data(let bytes, let filename): + return try await ComposerAttachmentImporter.importDraftData(bytes, filename: filename) + } + } +} diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentImporter.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentImporter.swift index fdb2a0f..c86341c 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentImporter.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentImporter.swift @@ -11,6 +11,7 @@ struct ComposerAttachmentImporter { case notFileURL(URL) case notReadableRegularFile(URL) case invalidClipboardImage + case invalidDraftSource(String) case cleanupRefused(URL) var errorDescription: String? { @@ -19,6 +20,7 @@ struct ComposerAttachmentImporter { case .notReadableRegularFile(let url): "Attachment is not a readable regular file: \(url.path)" case .invalidClipboardImage: "Clipboard data does not contain a valid image" + case .invalidDraftSource(let source): "Draft attachment has an invalid source: \(source)" case .cleanupRefused(let url): "Refusing to remove externally-owned attachment: \(url.path)" } } @@ -58,17 +60,17 @@ struct ComposerAttachmentImporter { { let materialized = try await Task.detached(priority: .userInitiated) { let png: Data - let digestSource: Data + if let pngData, let validPNG = try validPNGData(pngData) { png = validPNG - digestSource = pngData + } else if let tiffData, let convertedPNG = convertTIFFToPNG(tiffData) { png = convertedPNG - digestSource = tiffData + } else { throw ImportError.invalidClipboardImage } - let digest = SHA256.hash(data: digestSource).map { String(format: "%02x", $0) }.joined() + let digest = SHA256.hash(data: png).map { String(format: "%02x", $0) }.joined() let directory = temporaryDirectoryURL() try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -96,6 +98,48 @@ struct ComposerAttachmentImporter { ) } + static func importDraftData(_ data: Data, filename: String) async throws -> ComposerAttachment { + let url = try await Task.detached(priority: .userInitiated) { + let basename = (filename as NSString).lastPathComponent + guard !basename.isEmpty, basename != ".", basename != "..", !basename.contains("\0") else { + throw ImportError.invalidDraftSource(filename) + } + let directory = temporaryDirectoryURL().appendingPathComponent( + UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent(basename) + do { + try data.write(to: url, options: .atomic) + return url + } catch { + try? FileManager.default.removeItem(at: directory) + throw error + } + }.value + do { + let imported = try await importFile(at: url, storageOwnership: .appOwnedTemporaryFile) + + // Materialized copies have different filesystem identities. Deduplicate matching + // draft bytes and names, while preserving distinct filenames with equal content. + var identityData = Data() + if !imported.filename.hasPrefix("Clipboard Image ") { + identityData.append(contentsOf: imported.filename.utf8) + identityData.append(0) + } + identityData.append(data) + let digest = SHA256.hash(data: identityData).map { String(format: "%02x", $0) }.joined() + return ComposerAttachment( + id: imported.id, sourceURL: imported.sourceURL, filename: imported.filename, + contentType: imported.contentType, byteCount: imported.byteCount, + kind: imported.kind, storageOwnership: imported.storageOwnership, + deduplicationIdentity: .contentDigest(digest), pixelWidth: imported.pixelWidth, + pixelHeight: imported.pixelHeight, duration: imported.duration, preview: imported.preview) + } catch { + try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) + throw error + } + } + static func cleanupTemporaryAttachment(_ attachment: ComposerAttachment) throws { let temporaryDirectory = temporaryDirectoryURL().standardizedFileURL.path + "/" let attachmentPath = attachment.sourceURL.standardizedFileURL.path @@ -105,6 +149,14 @@ struct ComposerAttachmentImporter { throw ImportError.cleanupRefused(attachment.sourceURL) } try FileManager.default.removeItem(at: attachment.sourceURL) + let parent = attachment.sourceURL.deletingLastPathComponent() + if parent.deletingLastPathComponent().standardizedFileURL + == temporaryDirectoryURL().standardizedFileURL, + UUID(uuidString: parent.lastPathComponent) != nil, + (try? FileManager.default.contentsOfDirectory(atPath: parent.path).isEmpty) == true + { + try? FileManager.default.removeItem(at: parent) + } } private static func temporaryDirectoryURL() -> URL { @@ -119,10 +171,20 @@ struct ComposerAttachmentImporter { guard inputURL.isFileURL else { throw ImportError.notFileURL(inputURL) } let url = inputURL.standardizedFileURL.resolvingSymlinksInPath() - let values = try url.resourceValues(forKeys: [ - .isRegularFileKey, .fileSizeKey, .fileResourceIdentifierKey, - ]) - guard values.isRegularFile == true, FileManager.default.isReadableFile(atPath: url.path) else { + guard FileManager.default.fileExists(atPath: url.path), + FileManager.default.isReadableFile(atPath: url.path) + else { + throw ImportError.notReadableRegularFile(inputURL) + } + let values: URLResourceValues + do { + values = try url.resourceValues(forKeys: [ + .isRegularFileKey, .fileSizeKey, .fileResourceIdentifierKey, + ]) + } catch { + throw ImportError.notReadableRegularFile(inputURL) + } + guard values.isRegularFile == true else { throw ImportError.notReadableRegularFile(inputURL) } diff --git a/Tests/MactrixAppTests/ChatComposerStateTests.swift b/Tests/MactrixAppTests/ChatComposerStateTests.swift index 5c49306..b24c528 100644 --- a/Tests/MactrixAppTests/ChatComposerStateTests.swift +++ b/Tests/MactrixAppTests/ChatComposerStateTests.swift @@ -71,6 +71,18 @@ struct ChatComposerStateTests { #expect(FileManager.default.fileExists(atPath: externalURL.path)) } + @Test func draftRestorationIsSingleFlight() { + let composer = ChatComposerState() + + #expect(composer.beginDraftRestoration()) + #expect(!composer.beginDraftRestoration()) + composer.deferDraftRestoration() + #expect(composer.beginDraftRestoration()) + composer.completeDraftRestoration() + #expect(!composer.beginDraftRestoration()) + #expect(composer.isDraftLoaded) + } + @Test func canSendReflectsTextAndAttachments() { let composer = ChatComposerState() #expect(!composer.canSend) diff --git a/Tests/MactrixAppTests/ComposerAttachmentImporterTests.swift b/Tests/MactrixAppTests/ComposerAttachmentImporterTests.swift index bbc95c7..23d4779 100644 --- a/Tests/MactrixAppTests/ComposerAttachmentImporterTests.swift +++ b/Tests/MactrixAppTests/ComposerAttachmentImporterTests.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import MatrixRustSDK import Testing @testable import MactrixApp @@ -60,7 +61,8 @@ struct ComposerAttachmentImporterTests { try await ComposerAttachmentImporter.importFile(at: directory) } await #expect(throws: ComposerAttachmentImporter.ImportError.self) { - try await ComposerAttachmentImporter.importFile(at: URL(string: "https://example.com/file")!) + try await ComposerAttachmentImporter.importFile( + at: URL(string: "https://example.com/file")!) } let attachments = await ComposerAttachmentImporter.importFiles(at: [first, missing, second]) @@ -73,7 +75,8 @@ struct ComposerAttachmentImporterTests { let firstDirectory = directory.appendingPathComponent("one") let secondDirectory = directory.appendingPathComponent("two") try FileManager.default.createDirectory(at: firstDirectory, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: secondDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: secondDirectory, withIntermediateDirectories: true) let first = firstDirectory.appendingPathComponent("same-name.txt") let second = secondDirectory.appendingPathComponent("same-name.txt") try Data("one".utf8).write(to: first) @@ -102,6 +105,150 @@ struct ComposerAttachmentImporterTests { } } + @Test func draftAttachmentsUsePathSourcesAndAvailableMetadata() async throws { + try await withTemporaryDirectory { directory in + let fileURL = directory.appendingPathComponent("note.txt") + let imageURL = directory.appendingPathComponent("image.png") + let audioURL = directory.appendingPathComponent("audio.mp3") + let videoURL = directory.appendingPathComponent("video.mov") + try Data("hello".utf8).write(to: fileURL) + try pngData(width: 20, height: 10).write(to: imageURL) + try Data().write(to: audioURL) + try Data().write(to: videoURL) + + let file = try await ComposerAttachmentImporter.importFile(at: fileURL).draftAttachment + let image = try await ComposerAttachmentImporter.importFile(at: imageURL).draftAttachment + let audio = try await ComposerAttachmentImporter.importFile(at: audioURL).draftAttachment + let video = try await ComposerAttachmentImporter.importFile(at: videoURL).draftAttachment + + guard case .file(let info, .file(let path)) = file else { + Issue.record("Expected file draft") + return + } + #expect(path == fileURL.path) + let restored = try await ComposerAttachment.restoreDraftAttachment(file) + #expect(restored.sourceURL == fileURL.standardizedFileURL) + #expect(info.size == 5) + guard case .image(let info, .file(let path), _) = image else { + Issue.record("Expected image draft") + return + } + #expect(path == imageURL.path) + #expect(info.width == 20) + #expect(info.height == 10) + guard case .audio(_, .file(let audioPath)) = audio else { + Issue.record("Expected audio draft") + return + } + #expect(audioPath == audioURL.path) + guard case .video(_, .file(let videoPath), _) = video else { + Issue.record("Expected video draft") + return + } + #expect(videoPath == videoURL.path) + } + } + + @Test func draftRestorationRejectsMissingDirectoriesAndRestoresNativeData() async throws { + try await withTemporaryDirectory { directory in + let fileURL = directory.appendingPathComponent("valid.txt") + try Data("valid".utf8).write(to: fileURL) + + let restored = try await ComposerAttachment.restoreDraftAttachment( + .file( + fileInfo: FileInfo(mimetype: nil, size: nil, thumbnailInfo: nil, thumbnailSource: nil), + source: .file(filename: fileURL.path))) + #expect(restored.sourceURL == fileURL.standardizedFileURL) + #expect(restored.storageOwnership == .externalUserFile) + + await #expect(throws: ComposerAttachmentImporter.ImportError.self) { + try await ComposerAttachment.restoreDraftAttachment( + .file( + fileInfo: FileInfo(mimetype: nil, size: nil, thumbnailInfo: nil, thumbnailSource: nil), + source: .file(filename: directory.path))) + } + await #expect(throws: ComposerAttachmentImporter.ImportError.self) { + try await ComposerAttachment.restoreDraftAttachment( + .file( + fileInfo: FileInfo(mimetype: nil, size: nil, thumbnailInfo: nil, thumbnailSource: nil), + source: .file(filename: directory.appendingPathComponent("missing.txt").path))) + } + + let restoredData = try await ComposerAttachment.restoreDraftAttachment( + .file( + fileInfo: FileInfo(mimetype: nil, size: nil, thumbnailInfo: nil, thumbnailSource: nil), + source: .data(bytes: Data("data".utf8), filename: "data.txt"))) + defer { try? ComposerAttachmentImporter.cleanupTemporaryAttachment(restoredData) } + #expect(restoredData.storageOwnership == .appOwnedTemporaryFile) + #expect(try Data(contentsOf: restoredData.sourceURL) == Data("data".utf8)) + } + } + + @Test func nativeDraftDataSurvivesSourceDeletionAndPreservesFilename() async throws { + try await withTemporaryDirectory { directory in + let url = directory.appendingPathComponent("note.txt") + let bytes = Data("hello".utf8) + try bytes.write(to: url) + // This is the source representation returned by the SDK after saving a .file source. + let draft = DraftAttachment.file( + fileInfo: FileInfo( + mimetype: "text/plain", size: 5, thumbnailInfo: nil, thumbnailSource: nil), + source: .data(bytes: bytes, filename: url.lastPathComponent)) + try FileManager.default.removeItem(at: url) + let restored = try await ComposerAttachment.restoreDraftAttachment(draft) + defer { try? ComposerAttachmentImporter.cleanupTemporaryAttachment(restored) } + #expect(restored.filename == "note.txt") + #expect(restored.sourceURL.lastPathComponent == "note.txt") + #expect(try Data(contentsOf: restored.sourceURL) == bytes) + #expect(restored.storageOwnership == .appOwnedTemporaryFile) + let materializedDirectory = restored.sourceURL.deletingLastPathComponent() + try ComposerAttachmentImporter.cleanupTemporaryAttachment(restored) + #expect(!FileManager.default.fileExists(atPath: materializedDirectory.path)) + } + } + + @Test @MainActor func duplicateNativeDraftDataRestoresOnce() async throws { + let draft = DraftAttachment.file( + fileInfo: FileInfo(mimetype: nil, size: nil, thumbnailInfo: nil, thumbnailSource: nil), + source: .data(bytes: Data("hello".utf8), filename: "note.txt")) + let first = try await ComposerAttachment.restoreDraftAttachment(draft) + let second = try await ComposerAttachment.restoreDraftAttachment(draft) + defer { + try? ComposerAttachmentImporter.cleanupTemporaryAttachment(first) + try? ComposerAttachmentImporter.cleanupTemporaryAttachment(second) + } + let composer = ChatComposerState() + composer.addAttachments([first, second]) + #expect(composer.attachments.count == 1) + } + + @Test func nativeDraftDataRejectsInvalidFilenames() async { + for filename in ["", ".", "..", "bad\0name"] { + await #expect(throws: ComposerAttachmentImporter.ImportError.self) { + try await ComposerAttachmentImporter.importDraftData(Data(), filename: filename) + } + } + } + + @Test func clipboardDraftRestorationPreservesTemporaryOwnership() async throws { + let original = try await ComposerAttachmentImporter.importClipboardImage( + pngData: pngData(width: 4, height: 3), tiffData: nil) + defer { try? ComposerAttachmentImporter.cleanupTemporaryAttachment(original) } + let restored = try await ComposerAttachment.restoreDraftAttachment( + .image( + imageInfo: ImageInfo( + height: 3, width: 4, mimetype: "image/png", size: nil, + thumbnailInfo: nil, thumbnailSource: nil, blurhash: nil, isAnimated: nil), + source: .data(bytes: try Data(contentsOf: original.sourceURL), filename: original.filename), + thumbnailSource: nil)) + defer { try? ComposerAttachmentImporter.cleanupTemporaryAttachment(restored) } + #expect(restored.sourceURL != original.sourceURL) + #expect(restored.pixelWidth == 4) + #expect(restored.preview != nil) + #expect(restored.deduplicationIdentity == original.deduplicationIdentity) + #expect(restored.storageOwnership == .appOwnedTemporaryFile) + } + @Test func clipboardImagesAreTemporaryAndCleanupNeverDeletesExternalFiles() async throws { let externalDirectory = try makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: externalDirectory) } @@ -136,7 +283,8 @@ struct ComposerAttachmentImporterTests { } private func makeTemporaryDirectory() throws -> URL { - let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let url = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) return url } @@ -145,7 +293,8 @@ struct ComposerAttachmentImporterTests { let rep = NSBitmapImageRep( bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, - isPlanar: false, colorSpaceName: .deviceRGB, bitmapFormat: [], bytesPerRow: 0, bitsPerPixel: 0) + isPlanar: false, colorSpaceName: .deviceRGB, bitmapFormat: [], bytesPerRow: 0, bitsPerPixel: 0 + ) guard let rep, let data = rep.representation(using: .png, properties: [:]) else { throw CocoaError(.fileWriteUnknown) } @@ -156,7 +305,8 @@ struct ComposerAttachmentImporterTests { let rep = NSBitmapImageRep( bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, - isPlanar: false, colorSpaceName: .deviceRGB, bitmapFormat: [], bytesPerRow: 0, bitsPerPixel: 0) + isPlanar: false, colorSpaceName: .deviceRGB, bitmapFormat: [], bytesPerRow: 0, bitsPerPixel: 0 + ) guard let rep, let data = rep.tiffRepresentation else { throw CocoaError(.fileWriteUnknown) } return data } From 50a4334dcb9f340e2d1b91a7bd204102ae2ad997 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 5 Oct 2026 10:02:13 +0200 Subject: [PATCH 6/9] Implement single attachment uploading/sending --- .editorconfig | 5 + .../ChatInputView/ChatComposerState.swift | 154 ++++++++++++++++-- .../ChatInputView/ChatInputView.swift | 117 +------------ .../ChatInputView/ComposerAttachment.swift | 2 +- .../ComposerMediaPreparation.swift | 72 ++++++++ 5 files changed, 228 insertions(+), 122 deletions(-) create mode 100644 .editorconfig create mode 100644 Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerMediaPreparation.swift diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..de5e3c2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,5 @@ +root = true + +[*.swift] +indent_style = space +indent_size = 2 diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatComposerState.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatComposerState.swift index 000057d..d8e7c08 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatComposerState.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatComposerState.swift @@ -1,5 +1,7 @@ import Foundation +import MatrixIntegration import MatrixRustSDK +import OSLog @MainActor struct ComposerSubmissionSnapshot { @@ -20,6 +22,7 @@ final class ChatComposerState { private var isDraftRestoring = false private(set) var draftRevision = 0 private(set) var focusRequest = 0 + private(set) var isSending = false var canSend: Bool { !text.isEmpty || !attachments.isEmpty @@ -72,9 +75,63 @@ final class ChatComposerState { ) } + func sendMessage(timeline: Timeline) async { + guard !isSending else { return } + isSending = true + defer { isSending = false } + + let snapshot = captureSubmission() + + switch snapshot.attachments.count { + case 0: + guard !snapshot.text.isEmpty else { return } + do { + let message = messageEventContentFromMarkdown(md: snapshot.text) + if let replyID = snapshot.replyEventIdentifier { + _ = try await timeline.sendReply(msg: message, eventId: replyID) + } else { + _ = try await timeline.send(msg: message) + } + finishSending(snapshot: snapshot) + } catch { + Logger.viewCycle.error("failed to send message: \(error)") + } + + case 1: + guard let attachment = snapshot.attachments.first else { return } + do { + let upload = try await ComposerMediaPreparation.prepare( + attachment: attachment, caption: snapshot.text, replyID: snapshot.replyEventIdentifier) + let joinHandle: SendAttachmentJoinHandle + switch upload.media { + case .image(let info, let thumbnailSource): + joinHandle = try timeline.sendImage( + params: upload.parameters, thumbnailSource: thumbnailSource, imageInfo: info) + case .video(let info, let thumbnailSource): + joinHandle = try timeline.sendVideo( + params: upload.parameters, thumbnailSource: thumbnailSource, videoInfo: info) + case .audio(let info): + joinHandle = try timeline.sendAudio(params: upload.parameters, audioInfo: info) + case .file(let info): + joinHandle = try timeline.sendFile(params: upload.parameters, fileInfo: info) + } + + try await joinHandle.join() + finishSending(snapshot: snapshot, cleanupAttachments: true) + } catch { + Logger.composerAttachment.error( + "failed to enqueue attachment \(attachment.filename): \(error)") + } + + default: + Logger.composerAttachment.notice( + "Multiple attachments are pending; gallery upload support arrives in Phase 7") + } + } + /// Clears only content included in `snapshot`, preserving edits made while submission was prepared. /// Set `cleanupAttachments` to false when the sending layer needs to retain temporary sources. - func finishSending( + private func finishSending( snapshot: ComposerSubmissionSnapshot, cleanupAttachments: Bool = true ) { if text == snapshot.text { @@ -96,6 +153,88 @@ final class ChatComposerState { } } + func saveDraft(room: Room, timeline: LiveTimeline) async { + guard isDraftLoaded else { return } // avoid overwriting a draft before restoration + + if text.isEmpty, replyTarget == nil, attachments.isEmpty { + Logger.viewCycle.debug("clearing draft") + do { + try await room.clearComposerDraft(threadRoot: timeline.focusedThreadId) + } catch { + Logger.viewCycle.error("failed to clear draft: \(error)") + } + return + } + + let draftType: ComposerDraftType + if let replyTarget { + draftType = .reply(eventId: replyTarget.eventOrTransactionId.id) + } else { + draftType = .newMessage + } + let draft = ComposerDraft( + plainText: text, + htmlText: nil, + draftType: draftType, + attachments: attachments.map(\.draftAttachment) + ) + do { + try await room.saveComposerDraft(draft: draft, threadRoot: timeline.focusedThreadId) + } catch { + Logger.viewCycle.error("failed save draft: \(error)") + } + } + + func loadDraft(room: Room, timeline: LiveTimeline) async { + guard let innerTimeline = timeline.timeline else { return } + guard beginDraftRestoration() else { return } + + do { + guard let draft = try await room.loadComposerDraft(threadRoot: timeline.focusedThreadId) + else { + completeDraftRestoration() + return + } + text = draft.plainText + switch draft.draftType { + case .reply(let eventId): + do { + let item = try await innerTimeline.getEventTimelineItemByEventId(eventId: eventId) + restoreReply(to: item) + } catch { + Logger.viewCycle.error("failed to resolve reply target: \(error)") + } + case .newMessage, .edit: + break + } + + var discardedAttachment = false + for draftAttachment in draft.attachments { + let attachmentCount = attachments.count + do { + let attachment = try await ComposerAttachment.restoreDraftAttachment(draftAttachment) + addAttachments([attachment]) + if attachments.count == attachmentCount { + discardedAttachment = true // duplicate entries are saved only once + if attachment.storageOwnership == .appOwnedTemporaryFile { + try? ComposerAttachmentImporter.cleanupTemporaryAttachment(attachment) + } + } + } catch { + discardedAttachment = true + Logger.composerAttachment.warning("Discarding unavailable draft attachment: \(error)") + } + } + completeDraftRestoration() + if discardedAttachment { + await saveDraft(room: room, timeline: timeline) + } + } catch { + Logger.viewCycle.error("failed to load draft: \(error)") + completeDraftRestoration() + } + } + /// Clears the entire composer, including app-owned temporary attachment files. func reset() { let removedAttachments = attachments @@ -107,26 +246,17 @@ final class ChatComposerState { } /// Starts a single draft restoration. Returns false while another restoration is in progress. - func beginDraftRestoration() -> Bool { + private func beginDraftRestoration() -> Bool { guard !isDraftLoaded, !isDraftRestoring else { return false } isDraftRestoring = true return true } - /// Allows restoration to retry when a dependency, such as the timeline, is unavailable. - func deferDraftRestoration() { - isDraftRestoring = false - } - - func completeDraftRestoration() { + private func completeDraftRestoration() { isDraftRestoring = false isDraftLoaded = true } - func finishSending() { - finishSending(snapshot: captureSubmission()) - } - private func cleanupIfOwned(_ attachment: ComposerAttachment) { guard attachment.storageOwnership == .appOwnedTemporaryFile else { return } try? ComposerAttachmentImporter.cleanupTemporaryAttachment(attachment) diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift index cda2d4a..0d5f999 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatInputView.swift @@ -48,114 +48,8 @@ struct ChatInputView: View { return true } - func sendMessage() async { - guard !composer.text.isEmpty else { return } - guard let innerTimeline = timeline.timeline else { return } - - let msg = messageEventContentFromMarkdown(md: composer.text) - - do { - if let replyTarget = composer.replyTarget { - _ = try await innerTimeline.sendReply( - msg: msg, eventId: replyTarget.eventOrTransactionId.id - ) - } else { - _ = try await innerTimeline.send(msg: msg) - } - } catch { - Logger.viewCycle.error("failed to send message: \(error)") - } - - composer.finishSending() - } - - private func saveDraft() async { - guard composer.isDraftLoaded else { return } // avoid overwriting a draft before restoration - if composer.text.isEmpty, composer.replyTarget == nil, composer.attachments.isEmpty { - Logger.viewCycle.debug("clearing draft") - do { - try await room.clearComposerDraft(threadRoot: timeline.focusedThreadId) - } catch { - Logger.viewCycle.error("failed to clear draft: \(error)") - } - return - } - - let draftType: ComposerDraftType - if let replyTarget = composer.replyTarget { - draftType = .reply(eventId: replyTarget.eventOrTransactionId.id) - } else { - draftType = .newMessage - } - let draft = ComposerDraft( - plainText: composer.text, - htmlText: nil, - draftType: draftType, - attachments: composer.attachments.map(\.draftAttachment) - ) - do { - try await room.saveComposerDraft(draft: draft, threadRoot: timeline.focusedThreadId) - } catch { - Logger.viewCycle.error("failed save draft: \(error)") - } - } - - private func loadDraft() async { - guard composer.beginDraftRestoration() else { return } - do { - guard let draft = try await room.loadComposerDraft(threadRoot: timeline.focusedThreadId) - else { - composer.completeDraftRestoration() - return - } - composer.text = draft.plainText - switch draft.draftType { - case .reply(let eventId): - // We need a timeline to resolve a reply target, so retry when it becomes available. - guard let innerTimeline = timeline.timeline else { - composer.deferDraftRestoration() - return - } - do { - let item = try await innerTimeline.getEventTimelineItemByEventId(eventId: eventId) - composer.restoreReply(to: item) - } catch { - Logger.viewCycle.error("failed to resolve reply target: \(error)") - } - case .newMessage, .edit: - break - } - - var discardedAttachment = false - for draftAttachment in draft.attachments { - let attachmentCount = composer.attachments.count - do { - let attachment = try await ComposerAttachment.restoreDraftAttachment(draftAttachment) - composer.addAttachments([attachment]) - if composer.attachments.count == attachmentCount { - discardedAttachment = true // duplicate entries are saved only once - if attachment.storageOwnership == .appOwnedTemporaryFile { - try? ComposerAttachmentImporter.cleanupTemporaryAttachment(attachment) - } - } - } catch { - discardedAttachment = true - Logger.composerAttachment.warning("Discarding unavailable draft attachment: \(error)") - } - } - composer.completeDraftRestoration() - if discardedAttachment { - await saveDraft() - } - } catch { - Logger.viewCycle.error("failed to load draft: \(error)") - composer.completeDraftRestoration() - } - } - private func chatInputChanged() async { - guard composer.isDraftLoaded else { return } // avoid working on a draft being restored - await saveDraft() + await composer.saveDraft(room: room, timeline: timeline) if !composer.text.isEmpty { do { try await room.typingNotice(isTyping: !composer.text.isEmpty) @@ -194,7 +88,12 @@ struct ChatInputView: View { placeholder: "Message \(room.displayName() ?? "room")", disabled: !composer.isDraftLoaded, focusRequest: composer.focusRequest, - onSubmit: { Task { await sendMessage() } }, + onSubmit: { + Task { + guard let timeline = timeline.timeline else { return } + await composer.sendMessage(timeline: timeline) + } + }, onAttachmentPaste: handlePaste ) } @@ -205,7 +104,7 @@ struct ChatInputView: View { .task(id: timeline.timeline != nil) { // we need the timeline to be populated before we load a draft // (in case the draft holds a reply) - await loadDraft() + await composer.loadDraft(room: room, timeline: timeline) } } diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachment.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachment.swift index 78c4d1d..de648af 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachment.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachment.swift @@ -4,7 +4,7 @@ import MatrixRustSDK import UniformTypeIdentifiers struct ComposerAttachment: Identifiable { - enum Kind: Sendable { + enum Kind: Equatable, Sendable { case image case video case audio diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerMediaPreparation.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerMediaPreparation.swift new file mode 100644 index 0000000..4b6c7d6 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerMediaPreparation.swift @@ -0,0 +1,72 @@ + +import Foundation + +import MatrixRustSDK + + +struct PreparedMediaUpload { + enum Media { + case image(ImageInfo, thumbnailSource: UploadSource?) + case video(VideoInfo, thumbnailSource: UploadSource?) + case audio(AudioInfo) + case file(FileInfo) + } + + let attachment: ComposerAttachment + let parameters: UploadParameters + let media: Media +} + +enum ComposerMediaPreparation { + /// Re-importing immediately before enqueueing confirms that the path still names a + /// readable regular file and refreshes its size and media metadata. + static func prepare( + attachment: ComposerAttachment, caption: String, replyID: String? + ) async throws -> PreparedMediaUpload { + let refreshed = try await ComposerAttachmentImporter.importFile( + at: attachment.sourceURL, storageOwnership: attachment.storageOwnership) + let source = UploadSource.file(filename: refreshed.sourceURL.path) + let parameters = UploadParameters( + source: source, + caption: caption.isEmpty ? nil : caption, + formattedCaption: nil, + mentions: nil, + inReplyTo: replyID, + extraContentJson: nil) + let size = refreshed.byteCount.flatMap { $0 >= 0 ? UInt64($0) : nil } + + switch refreshed.kind { + case .image: + return PreparedMediaUpload( + attachment: refreshed, parameters: parameters, + media: .image( + ImageInfo( + height: refreshed.pixelHeight.map(UInt64.init), + width: refreshed.pixelWidth.map(UInt64.init), + mimetype: refreshed.mimeType, size: size, thumbnailInfo: nil, thumbnailSource: nil, + blurhash: nil, isAnimated: nil), + thumbnailSource: nil)) + case .video: + return PreparedMediaUpload( + attachment: refreshed, parameters: parameters, + media: .video( + VideoInfo( + duration: refreshed.duration, height: refreshed.pixelHeight.map(UInt64.init), + width: refreshed.pixelWidth.map(UInt64.init), mimetype: refreshed.mimeType, size: size, + thumbnailInfo: nil, thumbnailSource: nil, blurhash: nil), + thumbnailSource: nil)) + case .audio: + return PreparedMediaUpload( + attachment: refreshed, parameters: parameters, + media: .audio( + AudioInfo(duration: refreshed.duration, size: size, mimetype: refreshed.mimeType))) + case .file: + return PreparedMediaUpload( + attachment: refreshed, parameters: parameters, + media: .file( + FileInfo( + mimetype: refreshed.mimeType, size: size, thumbnailInfo: nil, thumbnailSource: nil))) + } + } + +} From e534f93ca6ba33b3ce6a20d811bdf863e335f4f7 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 5 Oct 2026 13:29:29 +0200 Subject: [PATCH 7/9] Fix preview by implementing PreviewPanelDataSource for the timeline --- .../ChatView/ChatInputView/ChatTextView.swift | 1 + .../ComposerAttachmentPreviewTile.swift | 5 +- .../TimelineView/MessageContentRowView.swift | 7 ++ .../TimelineView/MessageFileContentView.swift | 18 +++-- .../MessageImageContentView.swift | 19 +++-- .../TimelineView/MessageRowView.swift | 11 +++ .../TimelineView/TimelineTableView.swift | 75 +++++++++++++++++++ .../Views/MediaQuickLookPreview.swift | 30 -------- 8 files changed, 123 insertions(+), 43 deletions(-) delete mode 100644 Sources/MactrixApp/Views/MediaQuickLookPreview.swift diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift index d723c6d..6af68f7 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift @@ -1,5 +1,6 @@ import AppKit import OSLog +import QuickLookUI import SwiftUI struct ChatTextView: NSViewRepresentable { diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentPreviewTile.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentPreviewTile.swift index 850ac29..a17681f 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentPreviewTile.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerAttachmentPreviewTile.swift @@ -1,4 +1,5 @@ import AppKit +import QuickLook import SwiftUI import UniformTypeIdentifiers @@ -18,6 +19,7 @@ struct ComposerAttachmentPreviewTile: View { let attachment: ComposerAttachment let onRemove: () -> Void + @State private var previewURL: URL? func visual() -> Visual { if attachment.usesThumbnail, let preview = attachment.preview { @@ -47,7 +49,7 @@ struct ComposerAttachmentPreviewTile: View { VStack(spacing: 0) { ZStack(alignment: .topTrailing) { Button { - MediaQuickLookPreview.shared.show(url: attachment.sourceURL) + previewURL = attachment.sourceURL } label: { ZStack { switch visual() { @@ -100,6 +102,7 @@ struct ComposerAttachmentPreviewTile: View { .padding(.top, 4) .frame(width: Self.sideLength) } + .quickLookPreview($previewURL) } } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift index 0a9b0fe..3ae834b 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift @@ -10,6 +10,13 @@ protocol MessageContentRowView: NSView { func height(for content: MatrixRustSDK.MsgLikeContent, width: CGFloat) -> CGFloat } +/// Media rows deliver downloaded files to their owning timeline controller. +protocol MessageMediaPreviewContentView: MessageContentRowView { + /// Returns true when an existing preview was closed, avoiding another download. + var onMediaPreviewRequest: (() -> Bool)? { get set } + var onMediaPreview: ((URL, MediaFileHandle) -> Void)? { get set } +} + /// Determines the reusable row and its retained content view for a message. @MainActor enum MessageContentKind: Hashable { case text diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift index fe1c620..3bed8c6 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift @@ -4,7 +4,9 @@ import MatrixRustSDK import OSLog import UniformTypeIdentifiers -final class MessageFileContentView: NSView, MessageContentRowView { +final class MessageFileContentView: NSView, MessageMediaPreviewContentView { + var onMediaPreviewRequest: (() -> Bool)? + var onMediaPreview: ((URL, MediaFileHandle) -> Void)? var onSelectRequest: (() -> Void)? { didSet { captionView.onSelectRequest = onSelectRequest } } @@ -97,11 +99,13 @@ final class MessageFileContentView: NSView, MessageContentRowView { } @objc private func previewFile() { - guard let fileContent, let matrixClient else { return } + guard let fileContent, let window else { return } + if onMediaPreviewRequest?() == true { return } + guard let matrixClient else { return } let url = fileContent.source.url() fileButton.isEnabled = false previewTask?.cancel() - previewTask = Task { [weak self] in + previewTask = Task { [weak self, weak window] in do { let handle = try await matrixClient.client.getMediaFile( mediaSource: fileContent.source, @@ -112,9 +116,11 @@ final class MessageFileContentView: NSView, MessageContentRowView { ) try Task.checkCancellation() let path = try handle.path() - guard let self, self.sourceURL == url else { return } - MediaQuickLookPreview.shared.show( - handle: handle, url: URL(filePath: path, directoryHint: .notDirectory)) + guard let self, let window, self.window === window, self.sourceURL == url else { + return + } + self.onMediaPreview?( + URL(filePath: path, directoryHint: .notDirectory), handle) self.fileButton.setIcon(NSWorkspace.shared.icon(forFile: path)) self.fileButton.isEnabled = true self.previewTask = nil diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift index 77bec76..4354926 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift @@ -4,7 +4,9 @@ import MatrixRustSDK import OSLog /// One image and its optional text caption. Both views survive table row reuse. -final class MessageImageContentView: NSView, MessageContentRowView { +final class MessageImageContentView: NSView, MessageMediaPreviewContentView { + var onMediaPreviewRequest: (() -> Bool)? + var onMediaPreview: ((URL, MediaFileHandle) -> Void)? var onSelectRequest: (() -> Void)? { didSet { captionView.onSelectRequest = onSelectRequest } } @@ -191,10 +193,12 @@ final class MessageImageContentView: NSView, MessageContentRowView { } @objc private func previewImage() { - guard let imageContent, let matrixClient else { return } + guard let imageContent, let window else { return } + if onMediaPreviewRequest?() == true { return } + guard let matrixClient else { return } let url = imageContent.source.url() previewTask?.cancel() - previewTask = Task { [weak self] in + previewTask = Task { [weak self, weak window] in do { let handle = try await matrixClient.client.getMediaFile( mediaSource: imageContent.source, @@ -205,9 +209,12 @@ final class MessageImageContentView: NSView, MessageContentRowView { ) try Task.checkCancellation() let path = try handle.path() - guard let self, self.sourceURL == url else { return } - MediaQuickLookPreview.shared.show( - handle: handle, url: URL(filePath: path, directoryHint: .notDirectory)) + guard let self, let window, self.window === window, self.sourceURL == url else { + return + } + self.onMediaPreview?( + URL(filePath: path, directoryHint: .notDirectory), handle) + self.previewTask = nil } catch is CancellationError { return } catch { diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageRowView.swift index 36fd9be..33b20c0 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageRowView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageRowView.swift @@ -10,6 +10,17 @@ final class MessageRowView: NSView { var onHoverChange: ((MessageRowView, Bool, NSEvent) -> Bool)? var onSelectRequest: ((MessageRowView) -> Void)? var onArrowKey: ((TimelineSelectionDirection) -> Void)? + var onMediaPreviewRequest: (() -> Bool)? { + didSet { + (contentView as? any MessageMediaPreviewContentView)?.onMediaPreviewRequest = + onMediaPreviewRequest + } + } + var onMediaPreview: ((URL, MediaFileHandle) -> Void)? { + didSet { + (contentView as? any MessageMediaPreviewContentView)?.onMediaPreview = onMediaPreview + } + } private var isHovered = false private var isMessageSelected = false diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineTableView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineTableView.swift index cb9b8e5..dba1f72 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineTableView.swift @@ -3,6 +3,7 @@ import MatrixIntegration import MatrixProtocols import MatrixRustSDK import OSLog +import QuickLookUI enum TimelineSelectionDirection { case up @@ -80,6 +81,10 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate, LiveT private var hoveredMessageId: String? private var hoveredRowIndex: Int? private var updatingTimelineItems = false + private var previewURL: URL? + private var previewEventIdentifier: String? + // Keep SDK temporary media alive independently of the reusable row that loaded it. + private var previewFileHandle: MediaFileHandle? let timeline: LiveTimeline let composer: ChatComposerState @@ -187,6 +192,15 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate, LiveT view.onArrowKey = { [weak self] direction in self?.moveSelection(direction) } + view.onMediaPreviewRequest = { [weak self] in + self?.closeActiveMediaPreview(eventIdentifier: event.eventOrTransactionId.id) ?? false + } + view.onMediaPreview = { [weak self, weak view] url, handle in + guard let self, let view, view.window === self.view.window else { return } + self.selectMessageRow(for: view) + self.showMediaPreview( + url: url, handle: handle, eventIdentifier: event.eventOrTransactionId.id) + } view.configure( event: event, content: content, @@ -617,6 +631,67 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate, LiveT } } +extension TimelineViewController: @preconcurrency QLPreviewPanelDataSource { + private func closeActiveMediaPreview(eventIdentifier: String) -> Bool { + guard previewEventIdentifier == eventIdentifier, + QLPreviewPanel.sharedPreviewPanelExists(), let panel = QLPreviewPanel.shared(), + panel.isVisible, panel.currentController as AnyObject? === self, + let previewURL, panel.currentPreviewItem?.previewItemURL == previewURL + else { return false } + + panel.close() + return true + } + + private func showMediaPreview(url: URL, handle: MediaFileHandle, eventIdentifier: String) { + guard let window = view.window, let panel = QLPreviewPanel.shared() else { return } + previewFileHandle = handle + previewURL = url + previewEventIdentifier = eventIdentifier + + // Use the existing view/controller responder chain, bypassing text selection + // responders in captions and the composer that can otherwise claim Quick Look. + guard window.makeFirstResponder(tableView) else { return } + panel.updateController() + panel.makeKeyAndOrderFront(nil) + if panel.currentController as AnyObject? === self { + panel.reloadData() + panel.currentPreviewItemIndex = 0 + } else { + Logger.viewCycle.error("Timeline could not obtain Quick Look panel control") + } + } + + override func acceptsPreviewPanelControl(_ panel: QLPreviewPanel!) -> Bool { + MainActor.assumeIsolated { previewURL != nil } + } + + override func beginPreviewPanelControl(_ panel: QLPreviewPanel!) { + MainActor.assumeIsolated { + panel.dataSource = self + panel.reloadData() + panel.currentPreviewItemIndex = 0 + } + } + + override func endPreviewPanelControl(_ panel: QLPreviewPanel!) { + MainActor.assumeIsolated { + if panel.dataSource === self { + panel.dataSource = nil + } + } + } + + func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { + previewURL == nil ? 0 : 1 + } + + func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! { + guard index == 0 else { return nil } + return previewURL as NSURL? + } +} + extension TimelineViewController: NSTableViewDelegate { func selectionShouldChange(in tableView: NSTableView) -> Bool { return true diff --git a/Sources/MactrixApp/Views/MediaQuickLookPreview.swift b/Sources/MactrixApp/Views/MediaQuickLookPreview.swift deleted file mode 100644 index d8756e3..0000000 --- a/Sources/MactrixApp/Views/MediaQuickLookPreview.swift +++ /dev/null @@ -1,30 +0,0 @@ -import AppKit -import MatrixRustSDK -import QuickLookUI - -/// Presents a local file in Quick Look and retains an optional owner for temporary SDK media. -@MainActor final class MediaQuickLookPreview: NSObject, @preconcurrency QLPreviewPanelDataSource { - static let shared = MediaQuickLookPreview() - - private var retainedFileOwner: AnyObject? - private var url: URL? - - func show(url: URL, retaining fileOwner: AnyObject? = nil) { - retainedFileOwner = fileOwner - self.url = url - guard let panel = QLPreviewPanel.shared() else { return } - panel.dataSource = self - panel.reloadData() - panel.makeKeyAndOrderFront(nil) - } - - func show(handle: MediaFileHandle, url: URL) { - show(url: url, retaining: handle) - } - - func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { url == nil ? 0 : 1 } - - func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! { - url as NSURL? - } -} From 56030233efed9ad649a97a6e9bcb8280546eeada Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 5 Oct 2026 13:55:58 +0200 Subject: [PATCH 8/9] Implement gallery upload --- README.md | 2 +- .../ChatInputView/ChatComposerState.swift | 38 ++++++++++++++++-- .../ComposerMediaPreparation.swift | 39 +++++++++++++++++-- .../ChatComposerStateTests.swift | 12 ------ 4 files changed, 71 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b48aa1d..a134bcd 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Then open the project in Xcode and build using `Cmd+B`. - [ ] Multi account - [ ] Timeline - [x] Messages - - [ ] Send attachments + - [x] Send attachments - [x] Markdown formatting - [ ] Message actions - [x] Add reactions diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatComposerState.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatComposerState.swift index d8e7c08..2f49183 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatComposerState.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatComposerState.swift @@ -124,14 +124,46 @@ final class ChatComposerState { } default: - Logger.composerAttachment.notice( - "Multiple attachments are pending; gallery upload support arrives in Phase 7") + do { + let preparedAttachments = try await withThrowingTaskGroup { group in + for (i, attachment) in snapshot.attachments.enumerated() { + group.addTask { + let prepared = try await ComposerMediaPreparation.prepare( + attachment: attachment, caption: "", replyID: nil + ) + return (i, prepared) + } + } + + var result = Array.init( + repeating: nil, count: snapshot.attachments.count) + for try await (i, prepared) in group { + result[i] = prepared.asGalleryItemInfo + } + return result.compactMap { $0 } + } + + let joinHandle = try timeline.sendGallery( + params: GalleryUploadParameters( + caption: snapshot.text, + formattedCaption: nil, + mentions: nil, + inReplyTo: snapshot.replyEventIdentifier, + ), + itemInfos: preparedAttachments, + ) + + try await joinHandle.join() + finishSending(snapshot: snapshot, cleanupAttachments: true) + } catch { + Logger.composerAttachment.error("failed to enqueue attachments: \(error)") + } } } /// Clears only content included in `snapshot`, preserving edits made while submission was prepared. /// Set `cleanupAttachments` to false when the sending layer needs to retain temporary sources. - private func finishSending( + func finishSending( snapshot: ComposerSubmissionSnapshot, cleanupAttachments: Bool = true ) { if text == snapshot.text { diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerMediaPreparation.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerMediaPreparation.swift index 4b6c7d6..bc2758b 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerMediaPreparation.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ComposerMediaPreparation.swift @@ -1,9 +1,6 @@ - import Foundation - import MatrixRustSDK - struct PreparedMediaUpload { enum Media { case image(ImageInfo, thumbnailSource: UploadSource?) @@ -15,6 +12,41 @@ struct PreparedMediaUpload { let attachment: ComposerAttachment let parameters: UploadParameters let media: Media + + var asGalleryItemInfo: GalleryItemInfo { + switch self.media { + case .audio(let audio): + GalleryItemInfo.audio( + audioInfo: audio, + source: parameters.source, + caption: parameters.caption, + formattedCaption: nil, + ) + case .file(let file): + GalleryItemInfo.file( + fileInfo: file, + source: parameters.source, + caption: parameters.caption, + formattedCaption: nil, + ) + case .image(let image, let thumbnailSource): + GalleryItemInfo.image( + imageInfo: image, + source: parameters.source, + caption: parameters.caption, + formattedCaption: nil, + thumbnailSource: thumbnailSource, + ) + case .video(let video, let thumbnailSource): + GalleryItemInfo.video( + videoInfo: video, + source: parameters.source, + caption: parameters.caption, + formattedCaption: nil, + thumbnailSource: thumbnailSource, + ) + } + } } enum ComposerMediaPreparation { @@ -68,5 +100,4 @@ enum ComposerMediaPreparation { mimetype: refreshed.mimeType, size: size, thumbnailInfo: nil, thumbnailSource: nil))) } } - } diff --git a/Tests/MactrixAppTests/ChatComposerStateTests.swift b/Tests/MactrixAppTests/ChatComposerStateTests.swift index b24c528..5c49306 100644 --- a/Tests/MactrixAppTests/ChatComposerStateTests.swift +++ b/Tests/MactrixAppTests/ChatComposerStateTests.swift @@ -71,18 +71,6 @@ struct ChatComposerStateTests { #expect(FileManager.default.fileExists(atPath: externalURL.path)) } - @Test func draftRestorationIsSingleFlight() { - let composer = ChatComposerState() - - #expect(composer.beginDraftRestoration()) - #expect(!composer.beginDraftRestoration()) - composer.deferDraftRestoration() - #expect(composer.beginDraftRestoration()) - composer.completeDraftRestoration() - #expect(!composer.beginDraftRestoration()) - #expect(composer.isDraftLoaded) - } - @Test func canSendReflectsTextAndAttachments() { let composer = ChatComposerState() #expect(!composer.canSend) From 4fab20c542d7fa30dd1cf7fbf9e9b347b862432b Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 5 Oct 2026 14:37:08 +0200 Subject: [PATCH 9/9] Implement timeline media gallery row --- .../MessageAttachmentContent.swift | 87 +++++++++ .../TimelineView/MessageContentRowView.swift | 7 +- .../TimelineView/MessageFileContentView.swift | 16 ++ .../MessageGalleryContentView.swift | 180 ++++++++++++++++++ .../MessageImageContentView.swift | 20 ++ .../MessageVideoContentView.swift | 24 +++ .../MessageGalleryContentViewTests.swift | 118 ++++++++++++ 7 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 Sources/MactrixApp/Views/ChatView/TimelineView/MessageAttachmentContent.swift create mode 100644 Sources/MactrixApp/Views/ChatView/TimelineView/MessageGalleryContentView.swift create mode 100644 Tests/MactrixAppTests/MessageGalleryContentViewTests.swift diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageAttachmentContent.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageAttachmentContent.swift new file mode 100644 index 0000000..84161e7 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageAttachmentContent.swift @@ -0,0 +1,87 @@ +import AppKit +import MatrixIntegration +import MatrixRustSDK + +/// Attachment dispatch shared by gallery display and measurement. +@MainActor +final class MessageAttachmentContent { + enum Kind: Hashable { + case image, video, file, unsupported + + init(item: GalleryItemType) { + switch item { + case .image: self = .image + case .video: self = .video + case .file, .audio: self = .file + case .other: self = .unsupported + } + } + } + + let kind: Kind + let view: any MessageContentRowView + + init(item: GalleryItemType) { + kind = Kind(item: item) + switch kind { + case .image: view = MessageImageContentView() + case .video: view = MessageVideoContentView() + case .file: view = MessageFileContentView() + case .unsupported: view = MessageTextContentView() + } + } + + func configure(item: GalleryItemType, matrixClient: MatrixClient?) { + switch item { + case .image(let image): + (view as? MessageImageContentView)?.configure(image: image, matrixClient: matrixClient) + case .video(let video): + (view as? MessageVideoContentView)?.configure(video: video, matrixClient: matrixClient) + case .file(let file): + (view as? MessageFileContentView)?.configure(file: file, matrixClient: matrixClient) + case .audio(let audio): + (view as? MessageFileContentView)?.configure( + file: Self.fileFallback(for: audio), matrixClient: matrixClient) + case .other(let itemtype, let body): + (view as? MessageTextContentView)?.configureCaption( + "Unsupported attachment (\(itemtype)): \(body)", formatted: nil) + } + } + + func height(for item: GalleryItemType, width: CGFloat) -> CGFloat { + switch item { + case .image(let image): + return (view as? MessageImageContentView)?.height(for: image, width: width) ?? 0 + case .video(let video): + return (view as? MessageVideoContentView)?.height(for: video, width: width) ?? 0 + case .file(let file): + return (view as? MessageFileContentView)?.height(for: file, width: width) ?? 0 + case .audio(let audio): + return (view as? MessageFileContentView)?.height( + for: Self.fileFallback(for: audio), width: width) ?? 0 + case .other(let itemtype, let body): + return (view as? MessageTextContentView)?.height( + forCaption: "Unsupported attachment (\(itemtype)): \(body)", formatted: nil, width: width) + ?? 0 + } + } + + func prepareForReuse() { + (view as? MessageImageContentView)?.resetMedia() + (view as? MessageVideoContentView)?.resetMedia() + (view as? MessageFileContentView)?.resetMedia() + view.onSelectRequest = nil + view.onArrowKey = nil + if let preview = view as? any MessageMediaPreviewContentView { + preview.onMediaPreviewRequest = nil + preview.onMediaPreview = nil + } + } + + // Until an audio player is available, preserve its source and captions in a previewable file view. + private static func fileFallback(for audio: AudioMessageContent) -> FileMessageContent { + FileMessageContent( + filename: audio.filename, caption: audio.caption, formattedCaption: audio.formattedCaption, + source: audio.source, info: nil) + } +} diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift index 3ae834b..f647346 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift @@ -23,9 +23,12 @@ protocol MessageMediaPreviewContentView: MessageContentRowView { case image case video case file + case gallery init(content: MatrixRustSDK.MsgLikeContent) { - if MessageImageContentView.supports(content: content) { + if case .message(let message) = content.kind, case .gallery = message.msgType { + self = .gallery + } else if MessageImageContentView.supports(content: content) { self = .image } else if MessageVideoContentView.supports(content: content) { self = .video @@ -42,6 +45,7 @@ protocol MessageMediaPreviewContentView: MessageContentRowView { case .image: .init("message.image") case .video: .init("message.video") case .file: .init("message.file") + case .gallery: .init("message.gallery") } } @@ -52,6 +56,7 @@ protocol MessageMediaPreviewContentView: MessageContentRowView { case .image: contentView = MessageImageContentView() case .video: contentView = MessageVideoContentView() case .file: contentView = MessageFileContentView() + case .gallery: contentView = MessageGalleryContentView() } return MessageRowView(contentKind: self, contentView: contentView) } diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift index 3bed8c6..622c5f1 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift @@ -63,6 +63,10 @@ final class MessageFileContentView: NSView, MessageMediaPreviewContentView { case .file(let file) = message.msgType else { return } + configure(file: file, matrixClient: matrixClient) + } + + func configure(file: FileMessageContent, matrixClient: MatrixClient?) { fileContent = file self.matrixClient = matrixClient let mimeType = file.info?.mimetype.flatMap(UTType.init) ?? .data @@ -91,6 +95,10 @@ final class MessageFileContentView: NSView, MessageMediaPreviewContentView { guard case .message(let message) = content.kind, case .file(let file) = message.msgType else { return 0 } + return height(for: file, width: width) + } + + func height(for file: FileMessageContent, width: CGFloat) -> CGFloat { guard file.caption?.isEmpty == false || file.formattedCaption != nil else { return 36 } return 36 + 10 + ceil( @@ -135,6 +143,14 @@ final class MessageFileContentView: NSView, MessageMediaPreviewContentView { } } + func resetMedia() { + previewTask?.cancel() + previewTask = nil + sourceURL = nil + fileContent = nil + matrixClient = nil + } + deinit { previewTask?.cancel() } @available(*, unavailable) diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageGalleryContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageGalleryContentView.swift new file mode 100644 index 0000000..ea5a367 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageGalleryContentView.swift @@ -0,0 +1,180 @@ +import AppKit +import MatrixIntegration +import MatrixRustSDK + +/// One event's ordered attachments and final caption, retained within a single timeline row. +final class MessageGalleryContentView: NSView, MessageMediaPreviewContentView { + var onSelectRequest: (() -> Void)? { didSet { updateCallbacks() } } + var onArrowKey: ((TimelineSelectionDirection) -> Void)? { didSet { updateCallbacks() } } + var onMediaPreviewRequest: (() -> Bool)? { didSet { updateCallbacks() } } + var onMediaPreview: ((URL, MediaFileHandle) -> Void)? { didSet { updateCallbacks() } } + + private static let itemSpacing: CGFloat = 16 + private static let captionSpacing: CGFloat = 10 + private let captionView = MessageTextContentView() + private let captionMeasurementView = MessageTextContentView() + private var attachments: [MessageAttachmentContent] = [] + private var measurementViews: [MessageAttachmentContent.Kind: MessageAttachmentContent] = [:] + private var gallery: GalleryMessageContent? + private var compositionConstraints: [NSLayoutConstraint] = [] + private var attachmentHeightConstraints: [NSLayoutConstraint] = [] + private var captionHeightConstraint: NSLayoutConstraint? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + captionView.translatesAutoresizingMaskIntoConstraints = false + addSubview(captionView) + } + + func configure(content: MsgLikeContent, matrixClient: MatrixClient?) { + guard case .message(let message) = content.kind, + case .gallery(let gallery) = message.msgType + else { return } + configure(gallery: gallery, matrixClient: matrixClient) + } + + func configure(gallery: GalleryMessageContent, matrixClient: MatrixClient?) { + self.gallery = gallery + NSLayoutConstraint.deactivate(compositionConstraints) + compositionConstraints = [] + attachmentHeightConstraints = [] + captionHeightConstraint = nil + + var nextAttachments: [MessageAttachmentContent] = [] + for (index, item) in gallery.itemtypes.enumerated() { + let attachment: MessageAttachmentContent + if index < attachments.count, attachments[index].kind == .init(item: item) { + attachment = attachments[index] + } else { + if index < attachments.count { + attachments[index].prepareForReuse() + attachments[index].view.removeFromSuperview() + } + attachment = MessageAttachmentContent(item: item) + attachment.view.translatesAutoresizingMaskIntoConstraints = false + addSubview(attachment.view) + } + attachment.configure(item: item, matrixClient: matrixClient) + nextAttachments.append(attachment) + } + for attachment in attachments.dropFirst(gallery.itemtypes.count) { + attachment.prepareForReuse() + attachment.view.removeFromSuperview() + } + attachments = nextAttachments + + var previous: NSView? + for attachment in attachments { + let view = attachment.view + let height = view.heightAnchor.constraint(equalToConstant: 0) + attachmentHeightConstraints.append(height) + compositionConstraints += [ + view.leadingAnchor.constraint(equalTo: leadingAnchor), + view.trailingAnchor.constraint(equalTo: trailingAnchor), + view.topAnchor.constraint( + equalTo: previous?.bottomAnchor ?? topAnchor, + constant: previous == nil ? 0 : Self.itemSpacing), + height, + ] + previous = view + } + + let hasCaption = Self.hasCaption(gallery) + captionView.isHidden = !hasCaption + captionView.configureCaption(gallery.body, formatted: gallery.formatted) + if hasCaption { + let height = captionView.heightAnchor.constraint(equalToConstant: 0) + captionHeightConstraint = height + compositionConstraints += [ + captionView.leadingAnchor.constraint(equalTo: leadingAnchor), + captionView.trailingAnchor.constraint(equalTo: trailingAnchor), + captionView.topAnchor.constraint( + equalTo: previous?.bottomAnchor ?? topAnchor, + constant: previous == nil ? 0 : Self.captionSpacing), + height, + ] + previous = captionView + } + let bottom = + previous?.bottomAnchor.constraint(equalTo: bottomAnchor) + ?? heightAnchor.constraint(equalToConstant: 0) + bottom.priority = .init(999) + compositionConstraints.append(bottom) + updateHeights() + NSLayoutConstraint.activate(compositionConstraints) + updateCallbacks() + } + + override func layout() { + updateHeights() + super.layout() + } + + func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { + guard case .message(let message) = content.kind, + case .gallery(let gallery) = message.msgType + else { return 0 } + return height(for: gallery, width: width) + } + + func height(for gallery: GalleryMessageContent, width: CGFloat) -> CGFloat { + let width = max(width, 1) + let itemHeights = gallery.itemtypes.map { measure(item: $0, width: width) } + var height = + itemHeights.reduce(0, +) + + CGFloat(max(itemHeights.count - 1, 0)) * Self.itemSpacing + if Self.hasCaption(gallery) { + height += + (itemHeights.isEmpty ? 0 : Self.captionSpacing) + + ceil( + captionMeasurementView.height( + forCaption: gallery.body, formatted: gallery.formatted, width: width)) + } + return height + } + + private func measure(item: GalleryItemType, width: CGFloat) -> CGFloat { + let kind = MessageAttachmentContent.Kind(item: item) + let measurement = measurementViews[kind] ?? MessageAttachmentContent(item: item) + measurementViews[kind] = measurement + // Measurement never configures media and therefore cannot start a download or playback. + return ceil(measurement.height(for: item, width: width)) + } + + private func updateHeights() { + guard let gallery else { return } + let width = max(bounds.width, 1) + for (item, constraint) in zip(gallery.itemtypes, attachmentHeightConstraints) { + let height = measure(item: item, width: width) + if constraint.constant != height { constraint.constant = height } + } + if let captionHeightConstraint { + let height = ceil( + captionMeasurementView.height( + forCaption: gallery.body, formatted: gallery.formatted, width: width)) + if captionHeightConstraint.constant != height { captionHeightConstraint.constant = height } + } + } + + private func updateCallbacks() { + captionView.onSelectRequest = onSelectRequest + captionView.onArrowKey = onArrowKey + for attachment in attachments { + attachment.view.onSelectRequest = onSelectRequest + attachment.view.onArrowKey = onArrowKey + if let preview = attachment.view as? any MessageMediaPreviewContentView { + preview.onMediaPreviewRequest = onMediaPreviewRequest + preview.onMediaPreview = onMediaPreview + } + } + } + + private static func hasCaption(_ gallery: GalleryMessageContent) -> Bool { + !gallery.body.isEmpty || gallery.formatted != nil + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift index 4354926..860b595 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift @@ -89,6 +89,10 @@ final class MessageImageContentView: NSView, MessageMediaPreviewContentView { case .image(let image) = message.msgType else { return } + configure(image: image, matrixClient: matrixClient) + } + + func configure(image: ImageMessageContent, matrixClient: MatrixClient?) { self.imageContent = image self.matrixClient = matrixClient let hasCaption = image.caption?.isEmpty == false || image.formattedCaption != nil @@ -127,6 +131,10 @@ final class MessageImageContentView: NSView, MessageMediaPreviewContentView { guard case .message(let message) = content.kind, case .image(let image) = message.msgType else { return 0 } + return height(for: image, width: width) + } + + func height(for image: ImageMessageContent, width: CGFloat) -> CGFloat { let imageHeight = Self.imageSize(for: image, width: width).height guard image.caption?.isEmpty == false || image.formattedCaption != nil else { return imageHeight @@ -223,6 +231,18 @@ final class MessageImageContentView: NSView, MessageMediaPreviewContentView { } } + func resetMedia() { + loadTask?.cancel() + previewTask?.cancel() + loadTask = nil + previewTask = nil + sourceURL = nil + imageContent = nil + matrixClient = nil + imageButton.image = nil + spinner.stopAnimation(nil) + } + deinit { loadTask?.cancel() previewTask?.cancel() diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift index 261cc19..713ee58 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift @@ -113,6 +113,10 @@ final class MessageVideoContentView: NSView, MessageContentRowView { case .video(let video) = message.msgType else { return } + configure(video: video, matrixClient: matrixClient) + } + + func configure(video: VideoMessageContent, matrixClient: MatrixClient?) { videoContent = video self.matrixClient = matrixClient let hasCaption = video.caption?.isEmpty == false || video.formattedCaption != nil @@ -154,6 +158,10 @@ final class MessageVideoContentView: NSView, MessageContentRowView { guard case .message(let message) = content.kind, case .video(let video) = message.msgType else { return 0 } + return height(for: video, width: width) + } + + func height(for video: VideoMessageContent, width: CGFloat) -> CGFloat { let mediaHeight = Self.mediaSize(for: video, width: width).height guard video.caption?.isEmpty == false || video.formattedCaption != nil else { return mediaHeight @@ -274,6 +282,22 @@ final class MessageVideoContentView: NSView, MessageContentRowView { return view } + func resetMedia() { + thumbnailTask?.cancel() + videoTask?.cancel() + thumbnailTask = nil + videoTask = nil + playerView?.player?.pause() + playerView?.player = nil + playerView?.isHidden = true + fileHandle = nil + sourceURL = nil + videoContent = nil + matrixClient = nil + thumbnailView.image = nil + spinner.stopAnimation(nil) + } + @MainActor deinit { thumbnailTask?.cancel() diff --git a/Tests/MactrixAppTests/MessageGalleryContentViewTests.swift b/Tests/MactrixAppTests/MessageGalleryContentViewTests.swift new file mode 100644 index 0000000..ed53277 --- /dev/null +++ b/Tests/MactrixAppTests/MessageGalleryContentViewTests.swift @@ -0,0 +1,118 @@ +import AppKit +import MatrixRustSDK +import Testing + +@testable import MactrixApp + +@MainActor +struct MessageGalleryContentViewTests { + @Test func mixedGalleryMeasuresExistingRenderersAndFinalCaption() throws { + let source = try MediaSource.fromUrl(url: "mxc://example.org/attachment") + let image = ImageMessageContent( + filename: "image.png", caption: "Image caption", formattedCaption: nil, + source: source, info: nil) + let video = VideoMessageContent( + filename: "video.mp4", caption: "Video caption", formattedCaption: nil, + source: source, info: nil) + let file = FileMessageContent( + filename: "file.pdf", caption: "File caption", formattedCaption: nil, + source: source, info: nil) + let gallery = GalleryMessageContent( + body: "Final gallery caption", formatted: nil, + itemtypes: [.image(content: image), .video(content: video), .file(content: file)]) + let width: CGFloat = 240 + let expected = + ceil(MessageImageContentView().height(for: image, width: width)) + + ceil(MessageVideoContentView().height(for: video, width: width)) + + ceil(MessageFileContentView().height(for: file, width: width)) + + 32 + 10 + + ceil( + MessageTextContentView().height( + forCaption: gallery.body, formatted: nil, width: width)) + let view = MessageGalleryContentView() + #expect(view.height(for: gallery, width: width) == expected) + // Measuring requires no client and does not create displayed attachments. + #expect(view.subviews.count == 1) + + view.frame = NSRect(x: 0, y: 0, width: width, height: expected) + view.configure(gallery: gallery, matrixClient: nil) + view.layoutSubtreeIfNeeded() + let media = view.subviews.filter { !($0 is MessageTextContentView) } + #expect(media.count == 3) + #expect(media[0] is MessageImageContentView) + #expect(media[1] is MessageVideoContentView) + #expect(media[2] is MessageFileContentView) + #expect(abs(media[0].frame.minY - media[1].frame.maxY - 16) < 1) + #expect(abs(media[1].frame.minY - media[2].frame.maxY - 16) < 1) + let finalCaption = try #require(view.subviews.first { $0 is MessageTextContentView }) + #expect(abs(media[2].frame.minY - finalCaption.frame.maxY - 10) < 1) + #expect(abs(finalCaption.frame.minY) < 1) + #expect( + abs( + media[0].frame.height + - ceil( + MessageImageContentView().height( + for: image, width: width))) < 1) + } + + @Test func absentCaptionsAndEmptyGalleriesHaveNoExtraSpacing() throws { + let source = try MediaSource.fromUrl(url: "mxc://example.org/file") + let file = FileMessageContent( + filename: "file.pdf", caption: nil, formattedCaption: nil, source: source, info: nil) + let view = MessageGalleryContentView() + #expect( + view.height( + for: GalleryMessageContent(body: "", formatted: nil, itemtypes: []), width: 200) == 0) + #expect( + view.height( + for: GalleryMessageContent(body: "", formatted: nil, itemtypes: [.file(content: file)]), + width: 200) == 36) + #expect( + view.height( + for: GalleryMessageContent( + body: "", formatted: nil, itemtypes: [.file(content: file), .file(content: file)]), + width: 200) == 88) + } + + @Test func reuseRemovesOldChildrenAndKeepsCompatibleViews() throws { + let source = try MediaSource.fromUrl(url: "mxc://example.org/file") + let file = FileMessageContent( + filename: "file.pdf", caption: "Caption", formattedCaption: nil, source: source, info: nil) + let view = MessageGalleryContentView() + view.configure( + gallery: GalleryMessageContent( + body: "Gallery caption", formatted: nil, + itemtypes: [.file(content: file), .other(itemtype: "custom", body: "Unknown")]), + matrixClient: nil) + let original = try #require(view.subviews.first { $0 is MessageFileContentView }) + view.configure( + gallery: GalleryMessageContent(body: "", formatted: nil, itemtypes: [.file(content: file)]), + matrixClient: nil) + #expect(view.subviews.count == 2) + #expect(view.subviews.contains { $0 === original }) + #expect(view.subviews.compactMap { $0 as? MessageTextContentView }.allSatisfy { $0.isHidden }) + view.configure( + gallery: GalleryMessageContent(body: "", formatted: nil, itemtypes: []), matrixClient: nil) + #expect(view.subviews.count == 1) + } + + @Test func audioFallbackPreservesCaptionAndUnsupportedItemsRemainVisible() throws { + let audio = AudioMessageContent( + filename: "audio.ogg", caption: "Audio caption", formattedCaption: nil, + source: try MediaSource.fromUrl(url: "mxc://example.org/audio"), info: nil, + audio: nil, voice: nil) + let attachment = MessageAttachmentContent(item: .audio(content: audio)) + #expect(attachment.view is MessageFileContentView) + #expect(attachment.height(for: .audio(content: audio), width: 200) > 36) + let unknown = GalleryItemType.other(itemtype: "custom", body: "Unknown attachment") + #expect(MessageAttachmentContent(item: unknown).height(for: unknown, width: 200) > 0) + } + + @Test func captionsReflowWhenWidthChanges() { + let gallery = GalleryMessageContent( + body: String(repeating: "A long gallery caption. ", count: 10), formatted: nil, + itemtypes: []) + let view = MessageGalleryContentView() + #expect(view.height(for: gallery, width: 100) > view.height(for: gallery, width: 400)) + } +}