eleven / messenger Public

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>
Dermot Daly d2918f7df130d8d5eba4179afa0425638469c37f parent 91280c1
5 files changed, +229 −11
  • ios/LChat/Views/FilePreview.swift +53 −0
  • ios/LChat/Views/NativeChatScreen.swift +77 −9
  • ios/LChatCore/Sources/LChatCore/AttachmentDownload.swift +32 −0
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +16 −2
  • ios/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")
+ }
+}