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:
@@ -1,8 +1,10 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct RoomView: View {
|
struct RoomView: View {
|
||||||
@Bindable var store: TetherStore
|
@Bindable var store: TetherStore
|
||||||
@State private var draft = ""
|
@State private var draft = ""
|
||||||
|
@State private var showImporter = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -134,19 +136,29 @@ struct RoomView: View {
|
|||||||
private var feedList: some View {
|
private var feedList: some View {
|
||||||
List(store.feed) { msg in
|
List(store.feed) { msg in
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(msg.text ?? "")
|
if let image = msg.image {
|
||||||
.lineLimit(4)
|
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 {
|
HStack {
|
||||||
Text(msg.source ?? "?").font(.caption2)
|
Text(msg.isFile ? "file" : msg.source).font(.caption2)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(msg.date, style: .time).font(.caption2)
|
Text(msg.date, style: .time).font(.caption2)
|
||||||
}
|
}
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { store.copyToPasteboard(msg) }
|
.onTapGesture { if !msg.isFile { store.copyToPasteboard(msg) } }
|
||||||
.swipeActions {
|
.swipeActions {
|
||||||
Button("Copy") { store.copyToPasteboard(msg) }.tint(.blue)
|
if !msg.isFile {
|
||||||
|
Button("Copy") { store.copyToPasteboard(msg) }.tint(.blue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
@@ -161,6 +173,13 @@ struct RoomView: View {
|
|||||||
|
|
||||||
private var composer: some View {
|
private var composer: some View {
|
||||||
HStack {
|
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)
|
TextField("Type or paste…", text: $draft, axis: .vertical)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.lineLimit(1...4)
|
.lineLimit(1...4)
|
||||||
|
|||||||
@@ -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
|
/// UI-facing clipboard item. Decouples the view from the FFI `Message` record
|
||||||
/// and supplies the `id`/`date` SwiftUI's List needs.
|
/// and supplies the `id`/`date` SwiftUI's List needs.
|
||||||
struct FeedItem: Identifiable, Equatable {
|
struct FeedItem: Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
let text: String
|
let text: String // clipboard text, or the file name for files
|
||||||
let source: String
|
let source: String
|
||||||
let date: Date
|
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.
|
/// Observable bridge between the shared `tethercore.Engine` and SwiftUI.
|
||||||
@@ -105,6 +108,39 @@ final class TetherStore {
|
|||||||
engine?.send(text: text)
|
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.
|
/// Copy a received message back onto the local pasteboard.
|
||||||
func copyToPasteboard(_ item: FeedItem) {
|
func copyToPasteboard(_ item: FeedItem) {
|
||||||
UIPasteboard.general.string = item.text
|
UIPasteboard.general.string = item.text
|
||||||
@@ -143,6 +179,6 @@ private final class EngineBridge: MessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func onFile(name: String, mime: String, data: Data) {
|
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()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user