ios: tap a file attachment to preview it in Quick Look (#515)
The native iOS feed rendered file chips but never wired MessageBubble's onFileTap, so tapping a non-image attachment (a Word doc, a PDF) did nothing — while web downloads it in the browser and the Mac saves it to ~/Downloads. Wire onFileTap in NativeChatScreen (feed + reply-thread) to decrypt the blob via the existing ChatFeedModel.blobData, stage it to a temp file, and present QLPreviewController. Quick Look's built-in action button gives Save to Files / AirDrop / open-in — the iOS-native 'download'. The file chip grows a progress ring while the blob downloads (the same mechanism as the video card; the download-progress state is generalised from videoDownload to blobDownload). Decrypted plaintext is written only to a temp dir, burned when the preview closes. The temp filename is derived by a new shared, tested helper (AttachmentDownload.safeFilename): it strips any path from the sender-controlled name and borrows an extension from the MIME type when the name lacks one, so Quick Look routes to the right handler. No changelog entry yet — native change, held until the release is minted.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
5 files changed,
+229
−11
ios/LChat/Views/FilePreview.swift+53 −0ios/LChat/Views/NativeChatScreen.swift+77 −9ios/LChatCore/Sources/LChatCore/AttachmentDownload.swift+32 −0ios/LChatCore/Sources/LChatCore/MessageBubble.swift+16 −2ios/LChatCore/Tests/LChatCoreTests/AttachmentDownloadTests.swift+51 −0
diff --git a/ios/LChat/Views/FilePreview.swift b/ios/LChat/Views/FilePreview.swift| new file mode 100644 |
| index 0000000..b257125 |
| --- /dev/null |
| +++ b/ios/LChat/Views/FilePreview.swift |
| @@ -0,0 +1,53 @@ |
| +import QuickLook |
| +import SwiftUI |
| +import UIKit |
| + |
| +// 🤖 Quick Look preview for a downloaded attachment. NativeChatScreen.openFile |
| +// decrypts the blob and writes its bytes to a temp URL (cleaned up on dismiss); |
| +// this hands that URL to QLPreviewController, which renders PDFs, Office docs, |
| +// text, images, … and carries a built-in action button — Save to Files, |
| +// AirDrop, open-in — the iOS equivalent of the web file-chip's browser |
| +// download. Wrapped in a UINavigationController so a Done button and Quick |
| +// Look's own share button share one bar. |
| +struct QuickLookPreview: UIViewControllerRepresentable { |
| + let url: URL |
| + let onDone: () -> Void |
| + |
| + func makeCoordinator() -> Coordinator { Coordinator(url: url, onDone: onDone) } |
| + |
| + func makeUIViewController(context: Context) -> UINavigationController { |
| + let preview = QLPreviewController() |
| + preview.dataSource = context.coordinator |
| + // QLPreviewController owns its right-hand share/action button; we add |
| + // the Done on the left (a sheet swipe-down also dismisses). |
| + preview.navigationItem.leftBarButtonItem = UIBarButtonItem( |
| + barButtonSystemItem: .done, target: context.coordinator, |
| + action: #selector(Coordinator.done)) |
| + return UINavigationController(rootViewController: preview) |
| + } |
| + |
| + func updateUIViewController(_ controller: UINavigationController, context: Context) {} |
| + |
| + final class Coordinator: NSObject, QLPreviewControllerDataSource { |
| + private let item: Item |
| + private let onDone: () -> Void |
| + |
| + init(url: URL, onDone: @escaping () -> Void) { |
| + self.item = Item(url: url) |
| + self.onDone = onDone |
| + } |
| + |
| + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 } |
| + func previewController(_ controller: QLPreviewController, |
| + previewItemAt index: Int) -> QLPreviewItem { item } |
| + |
| + @objc func done() { onDone() } |
| + } |
| + |
| + // Quick Look keys its preview + title off the URL's filename/extension — |
| + // which AttachmentDownload.safeFilename has already made sound. |
| + private final class Item: NSObject, QLPreviewItem { |
| + let previewItemURL: URL? |
| + init(url: URL) { previewItemURL = url } |
| + } |
| +} |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index eda409b..0fa712c 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -84,6 +84,15 @@ struct NativeChatScreen: View { |
| @State private var sendQueued = false |
| @State private var viewerImage: IdentifiedImage? |
| @State private var playingVideo: PlayingVideo? |
| + // A downloaded file staged on disk for Quick Look, plus the temp directory |
| + // to burn once the preview closes (decrypted plaintext never lingers). |
| + @State private var filePreview: FilePreviewItem? |
| + @State private var previewCleanupDir: URL? |
| + |
| + struct FilePreviewItem: Identifiable { |
| + let id = UUID() |
| + let url: URL |
| + } |
| struct StagedUpload: Identifiable { |
| let id = UUID() |
| @@ -383,6 +392,12 @@ struct NativeChatScreen: View { |
| VideoViewer(player: item.player) { dismissCover { playingVideo = nil } } |
| .presentationBackground(.clear) |
| } |
| + // Tapping a file chip previews it in Quick Look (with the system |
| + // share button) once its bytes are decrypted to a temp file. |
| + .sheet(item: $filePreview, onDismiss: cleanupPreview) { item in |
| + QuickLookPreview(url: item.url) { filePreview = nil } |
| + .ignoresSafeArea() |
| + } |
| // /themes opens the per-server settings, where the theme picker lives. |
| .sheet(isPresented: $showThemeSettings) { |
| ServerSettingsSheet(store: store) |
| @@ -740,8 +755,9 @@ struct NativeChatScreen: View { |
| viewerImage = IdentifiedImage(id: att.id, image: image) |
| upgradeViewerImage(att) |
| }, |
| + onFileTap: { openFile($0) }, |
| onVideoTap: { att, _ in playVideo(att) }, |
| - downloading: videoDownload, |
| + downloading: blobDownload, |
| actions: MessageActions( |
| canEdit: model.canEdit(m), |
| onReply: { startReply(m) }, |
| @@ -849,8 +865,9 @@ struct NativeChatScreen: View { |
| viewerImage = IdentifiedImage(id: att.id, image: image) |
| upgradeViewerImage(att) |
| }, |
| + onFileTap: { openFile($0) }, |
| onVideoTap: { att, _ in playVideo(att) }, |
| - downloading: videoDownload, |
| + downloading: blobDownload, |
| actions: MessageActions( |
| canEdit: model.canEdit(m), |
| onReply: { startReply(m) }, |
| @@ -962,16 +979,17 @@ struct NativeChatScreen: View { |
| .padding(.bottom, 8) |
| } |
| - // The clip currently downloading + its 0…1 progress — the card's radial |
| - // ring, same as the Mac. |
| - @State private var videoDownload: (id: String, progress: Double)? |
| + // The attachment currently downloading + its 0…1 progress — drives the |
| + // video card's radial ring and the file chip's, same as the Mac. Shared |
| + // across both: a given attachment is either a video or a file, never both. |
| + @State private var blobDownload: (id: String, progress: Double)? |
| private func playVideo(_ att: Attachment) { |
| - guard videoDownload == nil else { return } |
| - videoDownload = (att.id, 0) |
| + guard blobDownload == nil else { return } |
| + blobDownload = (att.id, 0) |
| Task { |
| - let data = await model.blobData(att) { p in videoDownload = (att.id, p) } |
| - videoDownload = nil |
| + let data = await model.blobData(att) { p in blobDownload = (att.id, p) } |
| + blobDownload = nil |
| guard let data else { return } |
| let loader = MemoryVideoAsset(data: data, mimeType: att.type) |
| let player = AVPlayer(playerItem: AVPlayerItem(asset: loader.makeAsset())) |
| @@ -980,6 +998,56 @@ struct NativeChatScreen: View { |
| } |
| } |
| + // A file chip tap: fetch + decrypt the blob (the chip grows a progress ring |
| + // meanwhile), stage it to a temp file, and open Quick Look — whose share |
| + // button gives Save to Files / AirDrop / open-in. Writing plaintext to a |
| + // temp dir is fine here: it's the member's deliberate act (like the web's |
| + // file-chip download), and the dir is burned when the preview closes. |
| + private func openFile(_ att: Attachment) { |
| + guard blobDownload == nil else { return } |
| + blobDownload = (att.id, 0) |
| + // The web's 150ms grace: a fetch that finishes instantly never flashes |
| + // a ring — only one that outlives the grace grows it. |
| + let started = Date() |
| + Task { |
| + let data = await model.blobData(att) { p in |
| + if Date().timeIntervalSince(started) > 0.15 { blobDownload = (att.id, p) } |
| + } |
| + blobDownload = nil |
| + guard let data, |
| + let dest = stagePreviewFile( |
| + data, name: AttachmentDownload.safeFilename(name: att.name, mime: att.type)) |
| + else { |
| + setRouteNotice("Couldn’t open that file.") |
| + return |
| + } |
| + filePreview = FilePreviewItem(url: dest) |
| + } |
| + } |
| + |
| + // Write decrypted bytes into a fresh temp subdirectory so the file keeps its |
| + // real name (Quick Look titles + previews by filename). Returns the file URL |
| + // and records the directory for cleanup. |
| + private func stagePreviewFile(_ data: Data, name: String) -> URL? { |
| + let dir = FileManager.default.temporaryDirectory |
| + .appendingPathComponent("preview-\(UUID().uuidString)", isDirectory: true) |
| + do { |
| + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) |
| + let dest = dir.appendingPathComponent(name) |
| + try data.write(to: dest, options: .atomic) |
| + previewCleanupDir = dir |
| + return dest |
| + } catch { |
| + return nil |
| + } |
| + } |
| + |
| + private func cleanupPreview() { |
| + guard let dir = previewCleanupDir else { return } |
| + try? FileManager.default.removeItem(at: dir) |
| + previewCleanupDir = nil |
| + } |
| + |
| // ---- composer ------------------------------------------------------------ |
| private var composerCluster: some View { |
diff --git a/ios/LChatCore/Sources/LChatCore/AttachmentDownload.swift b/ios/LChatCore/Sources/LChatCore/AttachmentDownload.swift| new file mode 100644 |
| index 0000000..09e9771 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/AttachmentDownload.swift |
| @@ -0,0 +1,32 @@ |
| +import Foundation |
| +import UniformTypeIdentifiers |
| + |
| +// 🤖 Preparing a received attachment to hand to the OS (Quick Look, the Files |
| +// app, the share sheet). The blob's original name + MIME ride INSIDE the |
| +// encrypted payload — never server metadata — so a safe on-disk filename is |
| +// derived here from what the sender supplied. Native-only: the web leans on the |
| +// browser's `<a download>` for the name and extension, so there's no JS mirror |
| +// to keep in lockstep with (unlike AttachmentFormat's helpers). |
| +public enum AttachmentDownload { |
| + /// A safe local filename for saving/previewing an attachment. Strips any |
| + /// directory path from the sender-controlled name, falls back to "file" |
| + /// when it's missing or empty, and appends an extension derived from the |
| + /// MIME type when the name carries none — so Quick Look and the share sheet |
| + /// can pick the right handler. |
| + public static func safeFilename(name: String?, mime: String?) -> String { |
| + // lastPathComponent drops separators (a name like "../../etc/passwd" |
| + // collapses to "passwd"); an all-dots/-slashes name yields "" and takes |
| + // the fallback. |
| + let raw = (name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) |
| + var base = (raw as NSString).lastPathComponent |
| + if base.isEmpty || base == "." || base == ".." { base = "file" } |
| + // Keep the sender's extension when present; otherwise borrow one from |
| + // the MIME type so an extensionless upload still opens in the right app. |
| + let hasExt = !(base as NSString).pathExtension.isEmpty |
| + if !hasExt, let mime, !mime.isEmpty, |
| + let ext = UTType(mimeType: mime)?.preferredFilenameExtension { |
| + base += "." + ext |
| + } |
| + return base |
| + } |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift| index 9e9bfdf..f53f71a 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| @@ -181,6 +181,7 @@ public struct MessageBubble: View { |
| .draggableAttachment(dragItem?(att)) |
| } else { |
| FileChip(attachment: att, mine: message.mine, theme: theme, |
| + ringProgress: downloading?.id == att.id ? downloading?.progress : nil, |
| onTap: onFileTap) |
| } |
| } |
| @@ -654,11 +655,14 @@ public struct MessageBubble: View { |
| // A download chip for a non-image, non-video attachment — style.css |
| // .file-chip: emoji icon, name + size, bubble colours (me-tinted on own |
| - // messages). Tapping downloads (fetch + decrypt happens in the container). |
| + // messages). Tapping downloads (fetch + decrypt happens in the container); |
| + // while that runs the icon becomes a progress ring, mirroring the video |
| + // card's showVideoRing. |
| private struct FileChip: View { |
| let attachment: Attachment |
| let mine: Bool |
| let theme: ChatTheme |
| + let ringProgress: Double? // non-nil while this file downloads |
| let onTap: ((Attachment) -> Void)? |
| @Environment(\.colorScheme) private var scheme |
| @@ -666,7 +670,17 @@ public struct MessageBubble: View { |
| var body: some View { |
| Button { onTap?(attachment) } label: { |
| HStack(spacing: 6) { |
| - Text(fileIcon(attachment.name)).font(.system(size: 22)) |
| + if let ringProgress { |
| + ZStack { |
| + Circle().stroke(.primary.opacity(0.2), lineWidth: 2) |
| + Circle().trim(from: 0, to: max(0.05, ringProgress)) |
| + .stroke(style: StrokeStyle(lineWidth: 2, lineCap: .round)) |
| + .rotationEffect(.degrees(-90)) |
| + } |
| + .frame(width: 22, height: 22) |
| + } else { |
| + Text(fileIcon(attachment.name)).font(.system(size: 22)) |
| + } |
| VStack(alignment: .leading, spacing: 0) { |
| Text(attachment.name ?? "file") |
| .font(.system(size: 14)) |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/AttachmentDownloadTests.swift b/ios/LChatCore/Tests/LChatCoreTests/AttachmentDownloadTests.swift| new file mode 100644 |
| index 0000000..e3b5db4 |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/AttachmentDownloadTests.swift |
| @@ -0,0 +1,51 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// 🤖 The safe-filename derivation for downloading/previewing attachments: the |
| +// sender's name is untrusted, and the extension (which Quick Look and the Files |
| +// app route on) may be missing. These pin the sanitisation + MIME fallback. |
| +final class AttachmentDownloadTests: XCTestCase { |
| + func testKeepsGoodNameAndExtension() { |
| + XCTAssertEqual( |
| + AttachmentDownload.safeFilename(name: "report.pdf", mime: "application/pdf"), |
| + "report.pdf") |
| + XCTAssertEqual( |
| + AttachmentDownload.safeFilename(name: "photo.jpeg", mime: "image/jpeg"), |
| + "photo.jpeg") |
| + } |
| + |
| + func testDerivesExtensionFromMIMEWhenNameHasNone() { |
| + XCTAssertEqual( |
| + AttachmentDownload.safeFilename(name: "report", mime: "application/pdf"), |
| + "report.pdf") |
| + XCTAssertEqual( |
| + AttachmentDownload.safeFilename(name: "notes", mime: "text/plain"), |
| + "notes.txt") |
| + } |
| + |
| + func testFallsBackToFileWhenNameMissingOrEmpty() { |
| + XCTAssertEqual( |
| + AttachmentDownload.safeFilename(name: nil, mime: "application/pdf"), |
| + "file.pdf") |
| + XCTAssertEqual( |
| + AttachmentDownload.safeFilename(name: " ", mime: "image/png"), |
| + "file.png") |
| + } |
| + |
| + func testStripsDirectoryPathFromSenderName() { |
| + // lastPathComponent defuses a path-traversal-shaped name. |
| + XCTAssertEqual( |
| + AttachmentDownload.safeFilename(name: "../../etc/passwd", mime: nil), |
| + "passwd") |
| + XCTAssertEqual( |
| + AttachmentDownload.safeFilename(name: "a/b/c/secret.docx", mime: nil), |
| + "secret.docx") |
| + } |
| + |
| + func testNoExtensionWhenNeitherNameNorMIMEProvidesOne() { |
| + // No name extension and no usable MIME → leave it bare rather than invent one. |
| + XCTAssertEqual(AttachmentDownload.safeFilename(name: "notes", mime: nil), "notes") |
| + XCTAssertEqual(AttachmentDownload.safeFilename(name: "notes", mime: ""), "notes") |
| + XCTAssertEqual(AttachmentDownload.safeFilename(name: nil, mime: nil), "file") |
| + } |
| +} |