Files
Kecalek_python/ios_client 0.8.5/Kecalek/Views/Chat/MessageInputView.swift
2026-03-14 12:43:56 +01:00

216 lines
7.6 KiB
Swift

import SwiftUI
import PhotosUI
import UniformTypeIdentifiers
import UIKit
struct MessageInputView: View {
@Binding var text: String
let isSending: Bool
let onSend: () -> Void
var onImageSelected: ((Data) -> Void)?
var onFileSelected: ((Data, String, String) -> Void)? // data, filename, mimeType
var members: [ConversationMember] = []
@State private var isProcessing = false
@State private var showFilePicker = false
@State private var showPhotoPicker = false
@State private var showMentionPopup = false
@State private var mentionCandidates: [ConversationMember] = []
var body: some View {
VStack(spacing: 0) {
// Mention autocomplete popup
if showMentionPopup && !mentionCandidates.isEmpty {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ForEach(mentionCandidates) { member in
Button {
completeMention(member: member)
} label: {
Text("@\(member.username)")
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
Divider()
}
}
}
.frame(maxHeight: 150)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal)
}
HStack(spacing: 8) {
// Attach button
Menu {
Button {
showPhotoPicker = true
} label: {
Label("Photo", systemImage: "photo")
}
Button {
showFilePicker = true
} label: {
Label("File", systemImage: "doc")
}
} label: {
if isProcessing {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(.blue)
}
}
.disabled(isProcessing || isSending)
// Text field
TextField("Message", text: $text, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...5)
.onSubmit {
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
onSend()
}
}
// Send button
Button(action: onSend) {
if isSending {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
.foregroundStyle(.blue)
}
}
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
.sheet(isPresented: $showPhotoPicker) {
ImagePickerView { data in
isProcessing = true
onImageSelected?(data)
isProcessing = false
}
}
.sheet(isPresented: $showFilePicker) {
DocumentPickerView { url in
guard url.startAccessingSecurityScopedResource() else { return }
defer { url.stopAccessingSecurityScopedResource() }
guard let data = try? Data(contentsOf: url) else { return }
let filename = url.lastPathComponent
let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
onFileSelected?(data, filename, mimeType)
}
}
.onChange(of: text) {
updateMentionCandidates()
}
} // end VStack
}
private func updateMentionCandidates() {
// Look for @prefix at end of text
guard let atRange = text.range(of: "@\\w*$", options: .regularExpression) else {
showMentionPopup = false
mentionCandidates = []
return
}
let prefix = String(text[atRange]).dropFirst().lowercased() // remove @
mentionCandidates = members.filter { member in
prefix.isEmpty || member.username.lowercased().hasPrefix(prefix)
}
showMentionPopup = !mentionCandidates.isEmpty
}
private func completeMention(member: ConversationMember) {
if let atRange = text.range(of: "@\\w*$", options: .regularExpression) {
text.replaceSubrange(atRange, with: "@\(member.username) ")
}
showMentionPopup = false
mentionCandidates = []
}
}
// MARK: - Image Picker (UIKit PHPicker wrapper)
struct ImagePickerView: UIViewControllerRepresentable {
let onImagePicked: (Data) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onImagePicked: onImagePicked)
}
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .images
config.selectionLimit = 1
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
let onImagePicked: (Data) -> Void
init(onImagePicked: @escaping (Data) -> Void) {
self.onImagePicked = onImagePicked
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard let provider = results.first?.itemProvider,
provider.canLoadObject(ofClass: UIImage.self) else { return }
provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
guard let uiImage = image as? UIImage,
let data = uiImage.jpegData(compressionQuality: 0.9) else { return }
DispatchQueue.main.async {
self?.onImagePicked(data)
}
}
}
}
}
// MARK: - Document Picker (UIKit wrapper)
struct DocumentPickerView: UIViewControllerRepresentable {
let onPick: (URL) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onPick: onPick)
}
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item])
picker.delegate = context.coordinator
picker.allowsMultipleSelection = false
return picker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
class Coordinator: NSObject, UIDocumentPickerDelegate {
let onPick: (URL) -> Void
init(onPick: @escaping (URL) -> Void) {
self.onPick = onPick
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
onPick(url)
}
}
}