From 0195b1e872daf0798d795d622e5926e9bd7ac94d Mon Sep 17 00:00:00 2001 From: Patrick Ecord Date: Sun, 21 Jun 2026 12:16:03 -0500 Subject: [PATCH] =?UTF-8?q?ios:=20file/image=20UI=20=E2=80=94=20mirror=20t?= =?UTF-8?q?he=20Mac=20(inline=20preview,=20attach=20to=20send)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire on_file into the iOS app: received files save to Documents; images render inline in the feed, other files show a doc chip. A paperclip opens a file importer → send_file. FeedItem gains optional image/fileURL, matching macOS. Co-Authored-By: Claude Opus 4.8 --- ios/TetherApp/Sources/RoomView.swift | 29 ++++++++++++++--- ios/TetherApp/Sources/TetherStore.swift | 42 +++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/ios/TetherApp/Sources/RoomView.swift b/ios/TetherApp/Sources/RoomView.swift index 2915094..d90eb8c 100644 --- a/ios/TetherApp/Sources/RoomView.swift +++ b/ios/TetherApp/Sources/RoomView.swift @@ -1,8 +1,10 @@ import SwiftUI +import UniformTypeIdentifiers struct RoomView: View { @Bindable var store: TetherStore @State private var draft = "" + @State private var showImporter = false var body: some View { NavigationStack { @@ -134,19 +136,29 @@ struct RoomView: View { private var feedList: some View { List(store.feed) { msg in VStack(alignment: .leading, spacing: 4) { - Text(msg.text ?? "") - .lineLimit(4) + if let image = msg.image { + Image(uiImage: image) + .resizable().scaledToFit() + .frame(maxHeight: 220) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } else if msg.isFile { + Label(msg.text, systemImage: "doc.fill").lineLimit(1) + } else { + Text(msg.text).lineLimit(4) + } HStack { - Text(msg.source ?? "?").font(.caption2) + Text(msg.isFile ? "file" : msg.source).font(.caption2) Spacer() Text(msg.date, style: .time).font(.caption2) } .foregroundStyle(.secondary) } .contentShape(Rectangle()) - .onTapGesture { store.copyToPasteboard(msg) } + .onTapGesture { if !msg.isFile { store.copyToPasteboard(msg) } } .swipeActions { - Button("Copy") { store.copyToPasteboard(msg) }.tint(.blue) + if !msg.isFile { + Button("Copy") { store.copyToPasteboard(msg) }.tint(.blue) + } } } .listStyle(.plain) @@ -161,6 +173,13 @@ struct RoomView: View { private var composer: some View { HStack { + Button { showImporter = true } label: { + Image(systemName: "paperclip").font(.title2) + } + .disabled(!store.connected) + .fileImporter(isPresented: $showImporter, allowedContentTypes: [.item]) { result in + if case .success(let url) = result { store.sendFile(at: url) } + } TextField("Type or paste…", text: $draft, axis: .vertical) .textFieldStyle(.roundedBorder) .lineLimit(1...4) diff --git a/ios/TetherApp/Sources/TetherStore.swift b/ios/TetherApp/Sources/TetherStore.swift index 9524c04..db231a6 100644 --- a/ios/TetherApp/Sources/TetherStore.swift +++ b/ios/TetherApp/Sources/TetherStore.swift @@ -8,11 +8,14 @@ import enum TetherKit.RoomIdentity // selective: avoids TetherKit.Message clas /// UI-facing clipboard item. Decouples the view from the FFI `Message` record /// and supplies the `id`/`date` SwiftUI's List needs. -struct FeedItem: Identifiable, Equatable { +struct FeedItem: Identifiable { let id = UUID() - let text: String + let text: String // clipboard text, or the file name for files let source: String let date: Date + var image: UIImage? = nil // preview for image files + var fileURL: URL? = nil // saved location for any received file + var isFile: Bool { fileURL != nil } } /// Observable bridge between the shared `tethercore.Engine` and SwiftUI. @@ -105,6 +108,39 @@ final class TetherStore { engine?.send(text: text) } + /// Send a picked file P2P. + func sendFile(at url: URL) { + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + guard let data = try? Data(contentsOf: url) else { return } + engine?.sendFile(name: url.lastPathComponent, mime: Self.mime(for: url.pathExtension), data: data) + } + + static func mime(for ext: String) -> String { + switch ext.lowercased() { + case "png": return "image/png" + case "jpg", "jpeg": return "image/jpeg" + case "gif": return "image/gif" + case "heic": return "image/heic" + case "pdf": return "application/pdf" + case "txt": return "text/plain" + default: return "application/octet-stream" + } + } + + /// A received file: save to Documents, preview if it's an image. + fileprivate func ingestFile(name: String, mime: String, data: Data, date: Date) { + let image: UIImage? = mime.hasPrefix("image/") ? UIImage(data: data) : nil + var url: URL? + if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + let dest = docs.appendingPathComponent(name.isEmpty ? "tether-file" : name) + try? data.write(to: dest) + url = dest + } + feed.insert(FeedItem(text: name, source: "file", date: date, image: image, fileURL: url), at: 0) + if feed.count > 100 { feed.removeLast() } + } + /// Copy a received message back onto the local pasteboard. func copyToPasteboard(_ item: FeedItem) { UIPasteboard.general.string = item.text @@ -143,6 +179,6 @@ private final class EngineBridge: MessageHandler { } func onFile(name: String, mime: String, data: Data) { - // File receive UI is a follow-up; engine handles transport + reassembly. + Task { @MainActor in self.store?.ingestFile(name: name, mime: mime, data: data, date: Date()) } } }