ios: file/image UI — mirror the Mac (inline preview, attach to send)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 12:16:03 -05:00
parent b2a1e06f56
commit 0195b1e872
2 changed files with 63 additions and 8 deletions

View File

@@ -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)

View File

@@ -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()) }
}
}