Archived
feat: refine native iOS chat experience
This commit is contained in:
1 parent
9c5e9bce5b
commit
7bd893ae5c
14 files changed
+415
-93
No files matched your search
@@ -23,6 +23,9 @@ final class ChatFeatureStore: ObservableObject {
|
||||
@Published var previewText: String?
|
||||
@Published var previewImage: UIImage?
|
||||
@Published var previewPDF: Data?
|
||||
@Published var previewAttachment: ChatAttachment?
|
||||
@Published var previewLoading = false
|
||||
@Published var previewError: String?
|
||||
var onUnauthorized: (() async -> Void)?
|
||||
private let api: ChatTransport
|
||||
private var epoch = 0
|
||||
@@ -65,15 +68,36 @@ final class ChatFeatureStore: ObservableObject {
|
||||
} catch { report(error); return nil }
|
||||
}
|
||||
func preview(_ attachment: ChatAttachment) async {
|
||||
previewAttachment = attachment
|
||||
previewLoading = true
|
||||
previewError = nil
|
||||
previewPDF = nil; previewText = nil; previewImage = nil
|
||||
defer { if previewAttachment?.id == attachment.id { previewLoading = false } }
|
||||
do {
|
||||
let current = epoch
|
||||
let data = try await api.request("api/attachments/\(attachment.id)/content", method: "GET", body: nil, query: [])
|
||||
guard current == epoch, !closed else { return }
|
||||
previewPDF = nil; previewText = nil; previewImage = nil
|
||||
if attachment.mime.hasPrefix("image/") { previewImage = UIImage(data: data); previewText = nil }
|
||||
else if attachment.mime == "application/pdf" { previewPDF = data; previewText = nil; previewImage = nil }
|
||||
else { previewText = String(data: data, encoding: .utf8); previewImage = nil }
|
||||
} catch { report(error) }
|
||||
guard current == epoch, !closed, previewAttachment?.id == attachment.id else { return }
|
||||
if attachment.mime.hasPrefix("image/") {
|
||||
guard let image = UIImage(data: data) else { throw ChatFailure.invalidResponse }
|
||||
previewImage = image
|
||||
} else if attachment.mime == "application/pdf" {
|
||||
guard PDFDocument(data: data) != nil else { throw ChatFailure.invalidResponse }
|
||||
previewPDF = data
|
||||
} else if let text = String(data: data, encoding: .utf8) {
|
||||
previewText = text
|
||||
} else {
|
||||
throw ChatFailure.invalidResponse
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
guard previewAttachment?.id == attachment.id else { return }
|
||||
previewError = "Couldn’t open \(attachment.filename). Try again."
|
||||
}
|
||||
}
|
||||
func clearPreview() {
|
||||
previewAttachment = nil; previewLoading = false; previewError = nil
|
||||
previewText = nil; previewImage = nil; previewPDF = nil
|
||||
}
|
||||
func deleteAttachment(_ item: ChatAttachment) async {
|
||||
await remove("api/attachments/\(item.id)"); if error == nil { attachments.removeAll { $0.id == item.id } }
|
||||
@@ -111,7 +135,7 @@ final class ChatFeatureStore: ObservableObject {
|
||||
func saveMemory(_ item: ChatMemory, content: String) async {
|
||||
do { let result: ChatMemory = try await call("api/memory/\(item.section)", method: "PUT", body: ["revision": item.revision, "content": content]); if let i = memory.firstIndex(where: { $0.id == item.id }) { memory[i] = result }; error = nil } catch { report(error) }
|
||||
}
|
||||
func close() { closed = true; epoch += 1; enabled = []; attachments = []; artifacts = []; tasks = []; memory = []; previewText = nil; previewImage = nil; previewPDF = nil; error = nil }
|
||||
func close() { closed = true; epoch += 1; enabled = []; attachments = []; artifacts = []; tasks = []; memory = []; clearPreview(); error = nil }
|
||||
}
|
||||
|
||||
struct ChatLibraryView: View {
|
||||
@@ -121,6 +145,7 @@ struct ChatLibraryView: View {
|
||||
@State private var importing = false
|
||||
@State private var photo: PhotosPickerItem?
|
||||
@State private var showingPreview = false
|
||||
@State private var loadingCapabilities = true
|
||||
@State private var artifact: ChatArtifact?
|
||||
@State private var artifactEditor = false
|
||||
@State private var title = ""
|
||||
@@ -138,7 +163,11 @@ struct ChatLibraryView: View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if let error = store.error { Section { Text(error).foregroundStyle(.red); Button("Retry") { Task { await store.load() } } } }
|
||||
if store.enabled.isEmpty { ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown.")) }
|
||||
if loadingCapabilities {
|
||||
ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown."))
|
||||
} else if store.enabled.isEmpty {
|
||||
ContentUnavailableView("Library features aren’t available", systemImage: "lock", description: Text("Your server has not enabled private files, documents, schedules, or memory controls for this account."))
|
||||
}
|
||||
if store.enabled.contains("attachments") {
|
||||
Section("Files") {
|
||||
Button("Import from Files", systemImage: "doc.badge.plus") { importing = true }.disabled(store.busy)
|
||||
@@ -146,8 +175,13 @@ struct ChatLibraryView: View {
|
||||
ForEach(store.attachments) { item in
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Button { if selectedAttachmentIds.contains(item.id) { selectedAttachmentIds.remove(item.id) } else if selectedAttachmentIds.count < 8 { selectedAttachmentIds.insert(item.id) } } label: { Label(item.filename, systemImage: selectedAttachmentIds.contains(item.id) ? "checkmark.circle.fill" : "circle").frame(minHeight: 44) }.disabled(item.extractionStatus == "failed" || item.extractionStatus == "encrypted" || (item.mime.hasPrefix("image/") && !store.enabled.contains("image_input")))
|
||||
Button("Preview") { Task { await store.preview(item); showingPreview = true } }.frame(minHeight: 44)
|
||||
Button {
|
||||
if selectedAttachmentIds.contains(item.id) { selectedAttachmentIds.remove(item.id) }
|
||||
else if selectedAttachmentIds.count < 8 { selectedAttachmentIds.insert(item.id) }
|
||||
else { store.error = "Select up to 8 files for one message." }
|
||||
} label: { Label(item.filename, systemImage: selectedAttachmentIds.contains(item.id) ? "checkmark.circle.fill" : "circle").frame(minHeight: 44) }
|
||||
.disabled(item.extractionStatus == "failed" || item.extractionStatus == "encrypted" || (item.mime.hasPrefix("image/") && !store.enabled.contains("image_input")))
|
||||
Button("Preview") { showingPreview = true; Task { await store.preview(item) } }.frame(minHeight: 44)
|
||||
}
|
||||
Text("\(ByteCountFormatter.string(fromByteCount: Int64(item.size), countStyle: .file)) · \(item.extractionStatus)").font(.caption).foregroundStyle(.secondary)
|
||||
}.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteAttachment(item); selectedAttachmentIds.remove(item.id) } } }
|
||||
@@ -183,7 +217,7 @@ struct ChatLibraryView: View {
|
||||
}
|
||||
.navigationTitle("Library and tools")
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
|
||||
.task { await store.load() }
|
||||
.task { await store.load(); loadingCapabilities = false }
|
||||
.fileImporter(isPresented: $importing, allowedContentTypes: [.plainText, .pdf, .png, .jpeg, UTType(filenameExtension: "md") ?? .plainText, UTType(filenameExtension: "webp") ?? .image]) { result in
|
||||
if case .success(let url) = result { Task {
|
||||
let scoped = url.startAccessingSecurityScopedResource(); defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||||
@@ -197,10 +231,37 @@ struct ChatLibraryView: View {
|
||||
} }
|
||||
}
|
||||
.onChange(of: photo) { _, value in Task { if let data = try? await value?.loadTransferable(type: Data.self), data.count <= 8 * 1024 * 1024, let image = UIImage(data: data), let jpeg = image.jpegData(compressionQuality: 0.85) { _ = await store.upload(jpeg, filename: "photo.jpg", mime: "image/jpeg") } else if value != nil { store.error = "This photo could not be imported or exceeds 8 MiB. Choose a smaller JPEG or PNG." }; photo = nil } }
|
||||
.sheet(isPresented: $showingPreview) { NavigationStack { ScrollView { if let pdf = store.previewPDF { PrivatePDFPreview(data: pdf).frame(minHeight: 500) }; if let image = store.previewImage { Image(uiImage: image).resizable().scaledToFit() }; if let text = store.previewText { Text(text).textSelection(.enabled).padding() } }.navigationTitle("Private preview").toolbar { Button("Done") { showingPreview = false; store.previewImage = nil; store.previewText = nil; store.previewPDF = nil } } } }
|
||||
.sheet(isPresented: $showingPreview, onDismiss: { store.clearPreview() }) {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
if store.previewLoading { ProgressView("Opening private file…").frame(maxWidth: .infinity, minHeight: 200) }
|
||||
else if let error = store.previewError {
|
||||
ContentUnavailableView("Couldn’t open file", systemImage: "exclamationmark.triangle", description: Text(error))
|
||||
if let item = store.previewAttachment { Button("Try again") { Task { await store.preview(item) } } }
|
||||
} else if let pdf = store.previewPDF { PrivatePDFPreview(data: pdf).frame(minHeight: 500) }
|
||||
else if let image = store.previewImage { Image(uiImage: image).resizable().scaledToFit().padding() }
|
||||
else if let text = store.previewText { Text(text).textSelection(.enabled).padding() }
|
||||
}
|
||||
.navigationTitle(store.previewAttachment?.filename ?? "Private preview")
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showingPreview = false } } }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $artifactEditor) { artifactForm }
|
||||
.sheet(isPresented: $taskEditor) { taskForm }
|
||||
.sheet(item: $memory) { item in NavigationStack { Form { TextEditor(text: $memoryContent).frame(minHeight: 260); Text("An empty value clears this section. Existing sessions may cache earlier memory.").font(.footnote) }.navigationTitle(item.section.capitalized).toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { memory = nil } }; ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveMemory(item, content: memoryContent); if store.error == nil { memory = nil } } } } } } }
|
||||
.sheet(item: $memory) { item in
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Memory") { TextEditor(text: $memoryContent).frame(minHeight: 260).accessibilityLabel("Memory content") }
|
||||
Text("An empty value clears this section. Existing sessions may cache earlier memory.").font(.footnote)
|
||||
if let error = store.error { Text(error).foregroundStyle(.red) }
|
||||
}
|
||||
.navigationTitle(item.section.capitalized)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { memory = nil } }
|
||||
ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveMemory(item, content: memoryContent); if store.error == nil { memory = nil } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Delete this item?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) { Button("Delete", role: .destructive) { if let deleting { Task { await deleting() } }; deleting = nil } }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user